Applicative APIs and partial application with f(a, b, ..)
#1852
Replies: 6 comments 27 replies
-
This is a really good write up, thank you! I think the partial function application is the best approach we've seen to this so far. Let's think about this more |
Beta Was this translation helpful? Give feedback.
-
A question from nth on Discord
We would at least need to paramertise the type, but I'm not sure it's quite right still type SchemaBuilder(a, t) = fn(..a) -> Schema(t) |
Beta Was this translation helpful? Give feedback.
-
I don't have much experience on the PL design side of things, but I wanted to chime in and voice my support for this proposal. Clean yet super expressive. I love it. Using this feature, the following example ORM-like-thing can be fully implemented in about 150 lines of pure gleam (save for four external encoding/decoding functions). pub type City {
City(name: String, latitude: Float, longitude: Float, metadata: Metadata)
}
fn main() {
let city_schema = schema(table: "city", is: City)
|> field("name", string, fn(city) { city.name })
|> field("lat", float, fn(city) { city.latitude })
|> field("lng", float, fn(city) { city.longatude })
|> construct()
let cities = [
City(name: "New York", latatude: 40.71, longatude: 74.00, metadata: metadata()),
City(name: "Berlin", latatude: 52.52, longatude: 13.40, metadata: metadata()),
City(name: "London", latatude: 51.50, londatude: 00.12, metadata: metadata()),
];
// A stack is a collection of rows from multiple SQL tables
// type Stack = Map(String, Frame), where a frame represents some rows from a single table
// type Frame = Map(String, Series), where `Series` is a vector version of `Dynamic` representing a column
let stack: Stack = city_schema.encode(cities)
// Select just the cities frame
// Encoders return multile frames because objects can contain loaded assoceations
let frame = map.get(stack, "city")
// If we decode the frame, it should return the same values we started with!!!
assert Ok(cities) = city_schema.decode(frame)
} Here fn construct(schema: Schema(fn(Metadata) -> t)) -> Schema(t) instead of fn construct(schema: Schema(fn(a) -> t), a: a) -> Schema(t) we can require that the last field for any type used in the ORM is of type Metadata. Anything else is a type error when they try to write a schema. Neat, right? More generally, the separation of The row-poly syntax is perfectly inline with the behavior of the spread operator as defined on lists. Only one "spread" is allowed, and only after all the other arguments. The partial application syntax also seems great. I think if you bill it as "argument spreading" rather than "currying", new users will probably pick it up pretty quickly (if they have to touch it at all). Comments
Questions
|
Beta Was this translation helpful? Give feedback.
-
How would the code generation work for this? pub fn main(f: fn(Int, ..b) -> c) {
f(1, ..)
} I don't know what code we would generate for this. Don't we need to know the arity of |
Beta Was this translation helpful? Give feedback.
-
TL;DR;
Oké, it took me some time to sit down and write about this. Let me first try to summarise the problem stated by @hayleigh-dot-dev and @NthTensor, and then try to formalise it and pose a solution. Please let me know if anything is unclear or, even more importantly, if I’m describing a solution that doesn’t fit the posed problem. Problem@hayleigh-dot-dev starts describing the problem of decoding pipelines, which in Gleam currently need to be written like this: import gleam/function
let decoder: Decoder(Person) =
succeed(function.curry3(Person))
|> and_map(field("name", string))
|> and_map(field("age", int))
|> and_map(field("address", string)) The pain point here is that the From here, the discussion started for syntax to partially apply functions and for syntax to denote some sort of currying. TermsLet me introduce some terms, to make clear what we’re talking about. CurryingIn general, currying is turning a function of arity import function
let calc = fn(a, b, c) {
a * b * c
}
// calc : fn(Int, Int, Int) -> Int
let calc_curried = function.curry3(calc)
// calc_curried : fn(Int) -> fn(Int) -> fn(Int) -> Int As you can see, we spread all 3 parameters of let calc_always_curried = fn(a) { fn(b) { fn(c) {
a * b * c
} } } or enforce all functions are curried by default (as Haskell, OCaml, Elm, PureScript, etc. do) but there are reasons not to do this, like performance. This is what Gleam and Erlang choose to do. Note the difference in calling the functions: let result = calc(1, 2, 3) // all arguments need to be supplied
let result = calc_curried(1)(2)(3) // all arguments are provided separately Partial applicationPartial application is when a function gets supplied fewer arguments than needed to execute. So, if we have a curried function let calc_double = calc_curried(2)
// calc_double : fn(Int, Int) -> Int Note that currying is something that can make partial application possible. The proposition is to add syntax so that we can supply only the first parameter to the uncurried function let calc_double = calc(2, ..)
// calc_double : fn(Int, Int) -> Int The result is a function which only needs 2 arguments (now really 2). Similarly, we can provide the first two arguments and get a function taking just one argument: let calc_quadruple = calc(1, 4, ..)
// calc_quadruple : fn(Int) -> Int Curried function typeThen, there is another proposal to add some kind of curried function type (not an official term as far as I’m aware of, invented it myself). Note that this is something different from partial application and currying! It is about changing the contract a function exposes to the outer world by giving it another type signature. Take for example, using the proposed syntax, a function which needs a let use_two = fn(f: fn(Int, Int, ..r) -> a, x) {
f(x + 1, x - 1)
}
// use_two : fn(fn(Int, Int, ..r) -> a, Int) -> fn(..r) -> a The usage of this function would be something like this: let g = use_two(calc, 6)
// g : fn(Int) -> Int
let g = fn(x) { calc(6 + 1, 6 - 1, x) } ProposalLanguageOké, let’s set up our language. We’re using Gleam. All Gleam programs can be simplified to the following grammar: e ::= // Expressions
| x // variables
| let x = e_1; e_2 // let-binding
| fn(x^k) { e } // function abstraction
| f(e^k) // function application
| l // literal
t ::= // Types
| fn(t^k) -> t_0 // function type
| p // primitive type First a note on my grammar. The proposal is to add two pieces to the syntax, one new expression and one new type form. I’ll call the first one partial application and the second one auto currying (also not an official term). I’ll explain why in a minute. e ::= ...
| f(e^n, ..) // partial application
t ::= ...
| fn(t^n, ..t) -> t_0 // auto currying Here HelpersNext, let me introduce two functions (or actually two family of functions) called
You can think of We can define these functions in pseudo Gleam like this: // partial_n_k takes
// * a function f of arity k
// * n arguments e_1, ..., e_n
// it returns a function of
// * the remaining k-n arguments y_n+1, ..., y_k
// and applies the first k to f
fn partial_n_k(f, e^n) {
fn(y^k-n) { f(e^n, y^(k-n) }
}
// curry_n_k takes
// * a function f of arity k
// it returns a function which
// * first takes n arguments x_1, ..., x_n
// * then the remaining arguments x_n+1, ..., x_k
fn curry_n_k(f) {
fn(x^n) { fn(y^k-n) { f(x^n, y^(k-n) } }
// this is actually equivallent to:
// fn(x^n) { partial_n_k(f, x^n) }
} Now, my hypothesis is that, after type inference, we can automatically insert ExamplesPartial applicationWhen we have the partial application from the example above: let calc_quadruple = calc(1, 4, ..) we know that:
So calc(1, 4, ..) ~~> partial_2_3(calc, 1, 4) (Read Auto curryingFrom the example above: let use_two = fn(f: fn(Int, Int, ..r) -> a, x) {
f(x + 1, x - 1)
}
let g = use_two(calc, 6) We know that:
So calc ~~> curry_2_3(calc) And therefore use_two(calc, 6) ~~> use_two(curry_2_3(calc), 6) The signature of fn(fn(Int, Int, ..r) -> a, Int) -> fn(..r) -> a
~~>
fn(fn(Int, Int) -> k, Int) -> k Edge casesMaybe you’ll already have the feeling that some laws should apply to What if
|
Beta Was this translation helpful? Give feedback.
-
ReScript recently released a major update which changes the language to be uncurried by default rather than curried. They introduced similar syntax, |
Beta Was this translation helpful? Give feedback.
-
The problem
In Elm it's common to have these pipe-friendly APIs for things like decoding, parsing, and validating. A hypothetical decoder API might look something like:
The basic format involves starting with some constructor function that takes
n
arguments, and then chaining a bunch of operations withn
number ofand_maps
to slowly piece together something.Just using pipes on their own is insufficient because we're in some other "context", in this case a
Decoder
, or aParser
, or something else. The magic happens inand_map
. The type looks like this......or in gleam syntax...
This works in Elm (and others) specifically because Elm's functions are automatically curried. That is, a function
a -> b -> c
is actually a chain of single-argument functionsa -> (b -> c)
. In Gleam that means instead offn(a, b) -> c
we would havefn(a) -> fn(b) -> c
.We can produce a similar API in Gleam today, with the help of the curry utilities found in
gleam/function
...These APIs are very nice to consume, and have some advantages over alternative ones that might make use of
.then
, but we hit some resistance in Gleam because the explicit currying step. Currently there are utilities to curry functions up to six arguments:curry2
,curry3
, ...curry6
.This explicit currying is clunky and confusing. For those new to FP the word "curry" probably sooner conjures images of a delicious meal rather than some single-argument functions, and inevitably begs the question "why can't I just write
succeed(fn(name, age) { ... })
.The answer to that question is simple. We don't have a way to partially supply one (or some) argument(s) to a function.
The proposal
And with that I'd like to present a way to achieve that. I propose we re-use the existing spread syntax (
..
) as a way of partially applying a function. Here is a minimal example...Although not 1:1, this approach to partial application has parallels with existing usage of the spread syntax:
[ x, ..rest ]
Foo(..foo, bar: x)
The above example shows the syntax for partially applying function calls. The second part of this proposal is an extension of the type system to introduce the idea of "a function with at least this many arguments".
Consider again the type of
and_map
we saw earlier...This required currying to work nicely in the pipe, because we need that
b
to also be a function. A revised version ofand_map
using the proposed spread syntax becomes...The complete implementation could then be...
Now we can write...
Because the spread always implies there are additional arguments, unlike the Elm version we need a second function to act as the terminator in this style of pipe API. For that, we can re-use the initial type of
and_map
and just rename it......and with that we are left with...
Technical details
This essentially involves treating functions as a separate Kind, and introducing a form of row polymorphism to allow us to express the idea of "at least
n
arguments" in a type annotation and use function application to eliminate arguments from a type.If we look instead at records, this concept may make a bit more sense. A type like...
...is a record with the field
x : Int
andr
additional fields. This means all the following record types would unify with the above...We run into some additional constraints that seem practical to impose for our function version of row polymorphism. The "no additional rows is valid" case makes less immediate sense for functions. Given the row polymorphic function type...
...it feels like
f(Int) -> Int
should not unify here. Similarly, order must become significant for obvious reasons.Alternatives
@lpil and I have been thinking about this problem on-and-off for a while now. The current decoder API supplies a bunch of
decode{N}
functions, and an older version of a parser package I have provided a bunch ofsucceed{N}
functions in a similar fashion. This places a hard limit on the arity of the functions you can provide in these APIs.Another idea is to introduce a language construct for automatic currying, with a
curry
keyword. Making this a keyword/operator means we can side-step the type system and have a single way to curry arbitrary arity functions rather thancurry2
,curry3
, and so on. This has the same problem as today, where users are forced to what currying even is before they can consume some API.My thoughts are a little scattered on this. I have a good idea of what it would look like / how it would work in my head, but I've struggled to put it in writing. Thoughts, comments, and questions all very much welcome: I will do my best to clear things up!
Beta Was this translation helpful? Give feedback.
All reactions