This PR generalises `Base.Fix1` and `Base.Fix2` to `Base.Fix{n}`, to allow fixinā¦g a single positional argument of a function.
With this change, the implementation of these is simply
```julia
const Fix1{F,T} = Fix{1,F,T}
const Fix2{F,T} = Fix{2,F,T}
```
Along with the PR I also add a larger suite of unittests for all three of these functions to complement the existing tests for `Fix1`/`Fix2`.
### Context
There are multiple motivations for this generalization.
**By creating a more general `Fix{N}` type, there is no preferential treatment of certain types of functions:**
- (i) No limitation that you can only fix positions 1-2. You can now fix any position `n`.
- (ii) No asymmetry between 2-argument and n-argument functions. You can now fix an argument for functions with any number of arguments.
- (iii) ~~No asymmetry between positional arguments and keyword arguments. You can now fix a keyword argument.~~
Think of this like if `Base` only had `Vector{T}` and `Matrix{T}`, and you wished to generalise it to `Array{T,N}`.
It is an analogous situation here: `Fix1` and `Fix2` are now *aliases* of `Fix{N}`.
- **Convenience**:
- `Base.Fix1` and `Base.Fix2` are useful shorthands for creating simple anonymous functions without compiling new functions.
- They are very common throughout the Julia ecosystem as a shorthand for filling arguments:
- `Fix1` https://github.com/search?q=Base.Fix1+language%3Ajulia&type=code
- `Fix2` https://github.com/search?q=Base.Fix2+language%3Ajulia&type=code
- **Less Compilation**:
- Using `Fix*` reduces the need for compilation of repeatedly-used anonymous functions (which can often trigger compilation of new functions).
- **Type Stability**:
- `Fix`, like `Fix1` and `Fix2`, captures variables in a struct, encouraging users to use a functional paradigm for closures, preventing any potential type instabilities from boxed variables within an anonymous function.
- **Easier Functional Programming**:
- Allows for a stronger functional programming paradigm by supporting partial functions with _any number of arguments_.
Note that this refactors `Fix1` and `Fix2` to be equal to `Fix{1}` and `Fix{2}` respectively, rather than separate structs. This is backwards compatible.
Also note that this does not constrain future generalisations of `Fix{n}` for multiple arguments. `Fix{1,F,T}` is the clear generalisation of `Fix1{F,T}`, so this isn't major new syntax choices. But in a future PR you could have, e.g., `Fix{(n1,n2)}` for multiple arguments, and it would still be backwards-compatible with this.
### Details
As the names suggest, `Fix1` and `Fix2`, they can only inject arguments at the first and second index. Furthermore, they are also constrained to work on 2-argument functions exclusively. It seems at various points there had been interest in extending this (see links below) but nobody had gotten around to it so far.
This implementation of `Base.Fix` generalises the form as follows:
> `Fix{n}(f, x)`
> A type representing a partially-applied version of a function `f`, with the argument
> "x" fixed at argument `n::Int` or keyword `kw::Symbol`.
> In other words, `Fix{3}(f, x)` behaves similarly to
> `(y1, y2, y3) -> f(y1, y2, x, y3)` for the 4-argument function `f`.
With this more general type, I also rewrite `Fix1` and `Fix2` in this PR.
```julia
const Fix1{F,T} = Fix{1,F,T}
const Fix2{F,T} = Fix{2,F,T}
```
With `Fix{n}`, the code which is executed is roughly as follows:
```julia
function (f::Fix{N})(args...; kws...) where {N}
return f.f(args[begin:begin+(N-2)]..., f.x, args[begin+(N-1):end]...; kws...)
end
```
This means that the captured variable `x` is inserted at the `N`-th position. Keywords are also captured and inserted at the end.
This adds several unittests, a docstring, as well as type stability checks, for which it seems to succeed.
I also run the new `Fix1` and `Fix2` test suites added by this PR on the `Fix` version of each struct.
### Examples
**Simple examples:**
To fix the argument `f` with an anonymous function:
```julia
with_f = (a, b, c, d, e, g, h, i, j, k) -> my_func(a, b, c, d, e, f, g, h, i, j, k)
```
whereas now it becomes:
```julia
with_f = Base.Fix{6}(my_func, f)
```
I have some usecases like this in SymbolicRegression.jl. I want to fix a single option in a function, and then repeatedly call that function throughout some loop with different input. `Fix1` and `Fix2` are not general enough for this as they only allow 2-argument functions.
A more common use-case I have is to set `MIME"text/plain"` in `print` for tests, which can now be done as `Fix` is no longer limited to 2 arguments:
```julia
s = sprint(Fix{2}(print, MIME"text/plain"()), my_object)
```
without needing to re-compile at each instance.
**<details><summary>In a reduction:</summary>**
Fix1 and Fix2 are useful for short lambda functions like
```julia
sum(Base.Fix1(*, 2), [1, 2, 3, 4, 5])
```
to reduce compilation and often improve type stability.
With this new change, you aren't limited to only 2-arg functions, so you can use things like fma in this context:
```julia
sum(Base.Fix{2}(Base.Fix{3}(fma, 2.0), 0.5), [1, 2, 3, 4, 5])
```
where this will evaluate as x -> affine(x, 0.5, 2.0).
Another example is a mapreduce, where you would typically want to fix the map and reduction in applying:
```julia
sum(
Base.Fix{1}(Base.Fix{1}(mapreduce, abs), *),
[[1, -1], [2, -3, 4], [5]]
)
```
</details>
**<details><summary>In data processing pipelines:</summary>**
Fix can be used to set any number of arguments, and of course also be chained together repeatedly as there is no restriction on 2-arg functions. It makes functional programming easier than with only Fix1 and Fix2.
For example, in a processing pipeline:
```julia
using CSV, DataFrames
affine(a, b, c) = a .+ b .* c
affine_transform_df(path) = (
CSV.read(path, DataFrame)
|> dropmissing
|> Fix{1}(filter, :id => ==(7)) # Use like Fix2
|> Fix{2}(Fix{3}(affine, 2.0), 0.5) # Multiple args
|> Fix{2}(getproperty, :a)
)
affine_transform_df("data.csv")
```
</details>
**<details><summary>For dispatching on keyword-fixed functions:</summary>**
Say that I would like to dispatch on
`sum` for some type, if `dims` is set to an integer. You can do this as follows:
```julia
struct MyType
x::Float64
end
function (f::Base.Fix{:dims,typeof(sum),Int64})(ar::AbstractArray{MyType})
return sum(ar; dims=f.k.dims)
end
```
which would result in any use of `Fix(sum; dims=1)` operating on
`Vector{MyType}` to call this special function.
</details>
**<details><summary>Real-world examples**
I would like to use this in my code. Here are some examples:
</summary>
- https://github.com/MilesCranmer/SymbolicRegression.jl/blob/ea03242d099aa189cad3612291bcaf676d77451c/src/InterfaceDynamicExpressions.jl#L177-L191
- https://github.com/MilesCranmer/DataDrivenDiffEq.jl/blob/ba70d94dd851d5880fa670d6325296512b7435b3/src/solve/koopman.jl#L140
- https://github.com/MilesCranmer/pysr_paper/blob/b30687433fb32d3b9784fbedb1480d947dc46fc0/animations/optimization_example.jl#L33
- https://github.com/MilesCranmer/DispatchDoctor.jl/blob/02b46f6060c84c632dedf5e602dd3ca6a2c72d95/src/stabilization.jl#L212
- https://github.com/MilesCranmer/DispatchDoctor.jl/blob/02b46f6060c84c632dedf5e602dd3ca6a2c72d95/src/stabilization.jl#L222
- https://github.com/MilesCranmer/DispatchDoctor.jl/blob/02b46f6060c84c632dedf5e602dd3ca6a2c72d95/src/stabilization.jl#L233
- https://github.com/MilesCranmer/UniAdminTools.jl/blob/0f523364fc0f43446ff7c31f6ae3acf6c1ea927f/src/mergescore.jl#L289-L292
- https://github.com/MilesCranmer/DataDrivenDiffEq.jl/blob/ba70d94dd851d5880fa670d6325296512b7435b3/docs/examples/5_michaelis_menten.jl#L30
- https://github.com/MilesCranmer/DispatchDoctor.jl/blob/02b46f6060c84c632dedf5e602dd3ca6a2c72d95/test/llvm_ir_tests.jl#L8
For example,
```julia
function michaelis_menten(X::AbstractMatrix, p, t::AbstractVector)
reduce(hcat, map((x,ti)->michaelis_menten(x, p, ti), eachcol(X), t))
end
```
which could now be done with `map(Fix{2}(michaelis_menten, p), eachcol(X), t)`, reducing compilation costs, and avoiding any potential issues with capturing variables in the closure.
Another one would be this:
```julia
candidate_info_data = DataFrame((
name = string.(candidates),
score = (x -> round(x, digits = 3)).(summary_scores.mean),
uncertainty = (x -> round(x, digits = 2)).(summary_scores.std),
q25 = (x -> round(x, digits = 3)).(summary_scores_q[!, "25.0%"]),
q75 = (x -> round(x, digits = 3)).(summary_scores_q[!, "75.0%"]),
))
```
with this you could write `Fix(round; digits=3)` and not need to re-compile an anonymous function for each new outer method.
</details>
### Features in other languages
Here are some of the most related features in other languages (all that I could find; there's probably more)
#### Groovy's `.ncurry`
<details><summary>(Expand)</summary>
In Apache Groovy there is the `<function>.ncurry(index, args...)` to insert arguments at a given index. This syntax is semantically identical to `Base.Fix`.
From the [documentation](https://web.archive.org/web/20240522202230/https://groovy-lang.org/closures.html#_index_based_currying)
> In case a closure accepts more than 2 parameters, it is possible to set an arbitrary parameter using ncurry:
>
> ```groovy
> def volume = { double l, double w, double h -> l*w*h }
> def fixedWidthVolume = volume.ncurry(1, 2d)
> assert volume(3d, 2d, 4d) == fixedWidthVolume(3d, 4d)
> def fixedWidthAndHeight = volume.ncurry(1, 2d, 4d)
> assert volume(3d, 2d, 4d) == fixedWidthAndHeight(3d)
> ```
>
> 1. the `volume` function defines 3 parameters
> 2. `ncurry` will set the second parameter (index = 1) to 2d, creating a new volume function which accepts length and height
> 3. that function is equivalent to calling `volume` omitting the width
> 4. it is also possible to set multiple parameters, starting from the specified index
> 5. the resulting function accepts as many parameters as the initial one minus the number of parameters set by `ncurry`
</details>
#### Python's `functools.partial`
<details><summary>(Expand)</summary>
In Python, there is no differentiating between args and kwargs ā every function can be passed kwargs. Therefore, `functools.partial` is semantically similar to `Fix`:
```python
def f(a, b, c, d):
return a + b * c - d
f_with_b = functools.partial(f, b=2.0)
f_with_b(1.0, d=3.0)
```
which would be equivalent to `Fix{2}(f, 2.0)`.
</details>
#### C++'s `std::bind`
<details><summary>(Expand)</summary>
In modern C++, one can use `std::bind` to insert [placeholders](https://en.cppreference.com/w/cpp/utility/functional/bind). This is semantically closer to an anonymous function in Julia, though it *binds* the arguments in a way similar to `Fix1` and `Fix2` do:
```cpp
void f(int n1, int n2, int n3, const int& n4, int n5);
int main() {
using namespace std::placeholders
auto f2 = std::bind(f, _3, std::bind(g, _3), _3, 4, 5);
f2(10, 11, 12) // f(12, g(12), 12, 4, 5)
}
```
</details>
The C++ approach was also briefly mentioned on discourse [back in 2018](https://discourse.julialang.org/t/fix1-analogue-of-base-fix2/10161/12).
---
Closes:
- https://github.com/JuliaLang/julia/issues/50553
- https://github.com/JuliaLang/julia/issues/36181
Related issues:
- Initial discussion on Fix1 on [discourse](https://discourse.julialang.org/t/fix1-analogue-of-base-fix2/10161/9) which @tpapp added in https://github.com/JuliaLang/julia/pull/26708
- https://github.com/JuliaLang/julia/issues/15276
Other approaches:
- [FixArgs.jl](https://github.com/goretkin/FixArgs.jl) which stemmed out of https://github.com/JuliaLang/julia/issues/36181
- Note that this package takes a **much** different and more extensive approach to this problem using macros (see [docs](https://goretkin.github.io/FixArgs.jl/dev/#Symbolic-computation-and-lazy-evaluation)), so is likely not in-scope for merging to `Base`. I have written the `Base.Fix` in this PR from scratch based on the same patterns as `Fix1` and `Fix2` but with varargs.
- [AccessorsExtra.jl](https://github.com/JuliaAPlavin/AccessorsExtra.jl)
- The `FixArgs` implementation in this package is much more closely related to this PR. It works in a similar way although though stores the full signature with a `Placeholder()` set to the replaced arg.
- [FastBroadcast.jl](https://github.com/YingboMa/FastBroadcast.jl) defines an identical (aside from keywords) struct [here](https://github.com/YingboMa/FastBroadcast.jl/blob/9077379705b3d7d1677188c1e18d841daccdbe18/src/FastBroadcast.jl#L14-L18) for internal use
Semi-related issues:
- https://github.com/JuliaLang/julia/issues/554
- https://github.com/JuliaLang/julia/issues/5571
- https://github.com/JuliaLang/julia/pull/24990
- https://github.com/JuliaLang/julia/pull/36093
---
- **Edit 1**: ~~Made `Fix` work for `Vararg` so that you can insert multiple arguments at the specified index.~~
- **Edit 2**: Added keyword~~s~~ to `Fix`.
- **Edit 3**: Switched from `Fix(f, Val(1), arg)` syntax to `Fix{1}(f, arg)`.
- **Edit 4**: After triage, added back the keyword argument~~s~~, and rewrote `Fix1` and `Fix2` in terms of `Fix`.
- **Edit 5**: Added some validation checks for repeated keywords and non-`Int64` `n`
- **Edit 6**: Restricted the number of keyword arguments OR arguments to 1, and made struct more minimal.
- **Edit 7**: After second triage, various cleanup and simplification of code
- **Edit 8**: Removed the keyword argument. Now `Fix{n}` is exclusively for a single positional keyword argument.