My favorite description of HM inference is Section 4.4.4 in Samuel Mimram’s Proof=Program; they provide a description of level-based generalization which is more efficient than a generalize that needs to perform an occurs check to avoid generalizing variables not defined by the current binding (e.g. avoiding generalizing x in the definition of y in fun x -> let y = \z -> x in y, as the given example)
Algorithm M is interesting - it seems to have the localization benefits of systems like bidirectional type systems, while requiring no annotations
Regarding row polymorphism - Daan Leijen’s “Extensible records” paper is the simplest approach I know of, and is similar to the approach Elm/Roc use. At least in Roc, the approach is to bind a type variable to a record that unifies freely; for example the call (fun r -> r.x) {x: 1, y: 1} types the function as {x: a}b -> a (where b is free) and the record as {x: int, y: int}c (where c is free); {x: a}b ~ {x: int, y: int}c yields b = {y: int}d, c = {}d (where d is fresh). I don’t know of a good way to enforce that a record is “closed”, i.e. avoiding creating a fresh type variable representing expanding the width of the record, but I haven’t seen this be a problem in practice yet - but maybe there is an easy way?
And regarding variants, I think there is a simpler approach than OCaml’s approach by implementing the dual of the row-polymorphic approach above (which Roc also does)… that is, an expression `A 1 = `B "foo" is types the LHS and RHS as [A int]a and [B str]b respectively, the constraint [A int]a ~ [B str]b then solves a = [B str]c, b = [A int]c and both the LHS and RHS have type [A int, B str]c. In this case I think you do want to distinguish between extensible and non-extensible variants though, because a function [A, B]a -> {} is materially different than [A, B] -> {} - if you have a pattern match over the input on the former, you always need a catch-all branch, whereas a pattern match on the latter only needs a branch for A and B.
Anecdotally, subtyping seems to be more intuitive for folks than row polymorphism/polymorphic variants, but of course then it’s hard not to lose a lot of the niceties HM affords..
How are you doing with the Lambda Set stuff? It looks absolutely impenetrable and I’m wondering if you have hot tips or a better description. How hard would it be to build on top of what we already have for HM?
I have taken a bit of a break from it due to some other commitments. @rtfeldman is working on a better version of what is currently implemented.
My fault in the existing implementation was attempting to do it in a minimal number of passes relative to the rest of a compiler. Pulling it out to a separate pass as the original authors of the Lambda Set paper (Morphic) did makes it quite straightforward. I have a toy compiler (online playground) demonstrating it (sorry if it’s a bit opaque).
The main tip I would have is to do it in a separate pass once you already have already specialized all generic types, assuming you have a specializing compiler. At that point, run HM-style inference to determine how function calls are propagated in a program (this is actually equivalent to inferring polymorphic variants via the method described above). If you don’t have a specializing compiler, you give up being able to specialize all call sites but you can run the inference pass in or out of band to specialize calls that are known to be direct.
One last thing to note is that the lambda set-style stuff is disjoint from the type system, since lambda sets are directly encoding values, and not types per-se. So while you can reuse code between the two, fundamentally there is no reason to tie the type system to function-call specialization.
Sorry if this is still a bit opaque, happy to dive into more details as needed (here or in DM)!
We don’t have specialization right now because all we have is run-time type checks :) With the addition of HM inference we begin the specialization journey. So id = x -> x is still one IR node and one C function right now.
If we tack on the lambda set stuff, where do you think we should make cut points to specialize or at least change indirect calls to direct calls? We only have two passes right now (infer and C codegen) but would be interesting to add another before codegen. Maybe this will force us to create an actual useful IR.
EDIT: The other day I was also wondering if it was a good idea (possible?) do even do normal worklist call graph / CFA, just to see something working. Maybe it could be useful to additionally filter/refine by type but ¯\_(ツ)_/¯
if you tack on the lambda set stuff you would need to do an inference pass before codegen, but then you can codegen the direct calls in-line (the inference is sufficient to direct how calls should be transformed). I don’t think you necessarily need to have an intermediate IR unless you want to for other reasons too.
yeah, lambda sets is CFA with a unification-based approach.
Roughly, it’s a collection of possible function (ie lambda/closure) values you might expect in a given place (eg variable containing a function with a given signature).
By tracking this precisely the compiler (as an optimization) can store the various closures in a variant/sum type like data structure, enabling stack allocation of closures, inlining, etc. This can be faster than using function pointers and allocating every closure on the heap (another popular way due to simplicity of implementation).
Given most lambda variables would have a single concrete definition, it makes it simple for the compiler to eg inline the function call.
Different languages approach the problem in different ways (eg zig allows concrete function values or else function pointers, julia assigns a nominal type per function or closure but also allows for function pointers, etc), but this is another way which I’ve read about being applied to newer languages with ML-like type systems since the closures can just be treated like polymorphic variants and you get optimization benefits flowing pretty straightforwardly.
From what I understand this is more an issue with the design of Swift’s type system, i.e. the combination of subtyping, ad-hoc overloading (based on type, including return type polymorphism) and global type inference. This is well known to be a problem if you are familiar with the research on type checking, both for performance and usability, and there are approaches to deal with it, and to avoid the unrestricted mixing of problematic features as is seen in Swift.
You can have fast type inference, but it does mean designing your type system with that in mind. Adding in bidirectional typing to direct the flow of information through the type checker can be great for usability and performance too, giving type errors closer to the source of the problem based on annotations provided by the programmer.
Also note that Hindley Milner does not require generating and collecting constraints, you can also perform unification destructively as you go, which greatly improves performance (at the expense of biasing your type errors based on location. edit: looking at the blog post this is actually the approach it takes).
I asked Chris about this and he clarified that he meant that specifically in the context of Swift, not for languages in general. Here’s a recording where he clarified what he meant by that:
Also, the language that set the bar for high-quality error messages is Elm, which uses Hindley-Milner type inference and has a very fast compiler, and the Rust team cited Elm as the inspiration for the overhaul of their error messages which gave Rust a reputation for nice error messages:
Thanks for clarifying it. I would be curious to see the Reddit/HN comment (didn’t find it myself).
Also, the language that set the bar for high-quality error messages is Elm, which uses Hindley-Milner type inference and has a very fast compiler, and the Rust team cited Elm as the inspiration for the overhaul of their error messages which gave Rust a reputation for nice error messages:
Let me give you an example. Take this Rust code. The error message it produces:
error[E0597]: `ctx.field` does not live long enough
--> src/main.rs:10:27
|
9 | async fn async_fn(ctx: Context) {
| --- binding `ctx` declared here
10 | let mut field_guard = ctx.field.write().await;
| ^^^^^^^^^--------
| |
| borrowed value does not live long enough
| argument requires that `ctx.field` is borrowed for `'static`
11 | tokio::task::spawn_blocking(move || blocking_fn(&mut field_guard)).await;
12 | }
| - `ctx.field` dropped here while still borrowed
To me, the error message is hardly nice. I know what’s going on here: tokio::sync::RwLock::write() borrows ctx.field for lifetime 'a that’s limited to async_fn() scope. Whereas tokio::task::spawn_blocking() takes a closure which is 'static and thus all lifetimes within the closure must be 'static as well. A type error.
Now, look at how the explanation differs from the error message itself. There’s multiple problems here:
borrowed value does not live long enough points to ctx.field but there’s no borrowing (&) visible at this point in code. The actual borrowing is elsewhere — it’s the write(&self). It should show up in the error message IMO.
argument requires that ctx.field is borrowed for 'static doesn’t say, nor points to what “argument” exactly. There’s multiple arguments within the body of that function so it’s imprecise.
Finally, the biggest issue IMO, the error message doesn’t enumerate the reasoning chain the compiler performs so that a programmer could follow it and point out exactly the step at which it diverges from what’s happening inside of the programmer’s head. Ideally, it would look something like:
let mut field_guard = ctx.field.write().await;
^^^^^^^^^--------
|
`ctx.field` is borrowed here for lifetime 'x
let mut field_guard = ctx.field.write().await;
^^^^^^^^^^^
|
field_guard's lifetime is 'x
tokio::task::spawn_blocking(move || blocking_fn(&mut field_guard)).await;
^^^^^^^^^^^^^^^^
|
`field_guard` is borrowed here for lifetime 'y so 'x must live at least as long as 'y
tokio::task::spawn_blocking(move || blocking_fn(&mut field_guard)).await;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
expression lifetime is 'y
tokio::task::spawn_blocking(move || blocking_fn(&mut field_guard)).await;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
argument lifetime is 'y
fn spawn_blocking<F, R>(f: F) -> JoinHandle<R> where F: FnOnce() -> R + Send + 'static, R: Send + 'static
^^^^
|
parameter lifetime is 'static
tokio::task::spawn_blocking(move || blocking_fn(&mut field_guard)).await;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
argument lifetime is 'static
=> Thus 'y = 'static
=> Error: Unable to prove 'x lives at least as long as 'static
Oh I just brought up the Rust error messages as an example of a non-HM language that overall has a reputation for nice error messages (your mileage may vary, but the common view is that Rust’s error message quality is among the best) and that its error messages were inspired by a HM language.
Respondents aren’t content with the current error messages, but those error messages are a damn sight better than most of the competition! See how a recent post advises users into letting the compiler guide them: https://steveklabnik.com/writing/when-should-i-use-string-vs-str/. Compare that to TypeScript’s errors, which are unhelpful at best.
I’ll agree that Rust has great error messages until it doesn’t, and the situations where it doesn’t are usually the most annoying parts of the language like async, which I think is one of those situations and the example you highlight, so I’d also vote that error messages need work but would still think they are nice.
I speculate that “has a reputation for nice error messages” has an unsaid qualification “among programming language designers who appreciate the complexity of the problem”.
The common view is that Rust’s error message quality is among the best
Nevertheless, 98% of Rust users are not content with them and want them to be better
I don’t have any hard data to back up the claim that it’s a common view, but I just asked Perplexity “what programming languages have the nicest compiler error messages?” and its response listed Rust first, Elm second, and “TypeScript and Go” third.
Maybe I don’t understand what context-insensitive means here, but I just want to point out that if you infer types of variables without looking at the uses and you have subtyping, you can turn non-breaking changes into breaking changes. Example: https://gist.github.com/osa1/b17e09f84c33c0d478640e6d7811ed37.
This actually happened to me in real code and I had to revert a change.
If the type checker took the uses of the variable x into account this would work as expected.
You could not do type inference and ask the programmer to annotate variables with types, but that can easily get tedious, in most cases the type will be obvious, so that’s also not ideal.
One thing the resonated with me was “the behaviour of dynamically typed languages makes sense to users”. I heard a similar thing firsthand from one of the creators of Julia. Perhaps not an unpopular opinion but one not spoken openly about enough.
To me, the fact that backtracking inference affects the program behaviour (by eg casting values) confuses the hell out of me. (The latest example being me learning rust - all the auto casting of various refs lead me to learn the wrong behavior…).
Ipso has polymorphic records and variants. Unfortunately I don’t have a recommendation for a condensed learning resource. I just read Daan Leijen’s papers dozens of times and implemented the ideas in a few different projects.
You are looking for a reference to Hindley’s original contribution. Is this it?
Hindley, J. R. [1969], The principal type-scheme of an object in combinatory logic, Transactions of the American Mathematical Society, vol. 146, pp. 29–60.
I can’t check because I don’t have online access to this paper.
Awesome post!
My favorite description of HM inference is Section 4.4.4 in Samuel Mimram’s Proof=Program; they provide a description of level-based generalization which is more efficient than a
generalizethat needs to perform an occurs check to avoid generalizing variables not defined by the current binding (e.g. avoiding generalizingxin the definition ofyinfun x -> let y = \z -> x in y, as the given example)Algorithm M is interesting - it seems to have the localization benefits of systems like bidirectional type systems, while requiring no annotations
Regarding row polymorphism - Daan Leijen’s “Extensible records” paper is the simplest approach I know of, and is similar to the approach Elm/Roc use. At least in Roc, the approach is to bind a type variable to a record that unifies freely; for example the call
(fun r -> r.x) {x: 1, y: 1}types the function as{x: a}b -> a(wherebis free) and the record as{x: int, y: int}c(wherecis free);{x: a}b ~ {x: int, y: int}cyieldsb = {y: int}d, c = {}d(wheredis fresh). I don’t know of a good way to enforce that a record is “closed”, i.e. avoiding creating a fresh type variable representing expanding the width of the record, but I haven’t seen this be a problem in practice yet - but maybe there is an easy way?And regarding variants, I think there is a simpler approach than OCaml’s approach by implementing the dual of the row-polymorphic approach above (which Roc also does)… that is, an expression
`A 1 = `B "foo"is types the LHS and RHS as[A int]aand[B str]brespectively, the constraint[A int]a ~ [B str]bthen solvesa = [B str]c, b = [A int]cand both the LHS and RHS have type[A int, B str]c. In this case I think you do want to distinguish between extensible and non-extensible variants though, because a function[A, B]a -> {}is materially different than[A, B] -> {}- if you have a pattern match over the input on the former, you always need a catch-all branch, whereas a pattern match on the latter only needs a branch forAandB.Anecdotally, subtyping seems to be more intuitive for folks than row polymorphism/polymorphic variants, but of course then it’s hard not to lose a lot of the niceties HM affords..
How are you doing with the Lambda Set stuff? It looks absolutely impenetrable and I’m wondering if you have hot tips or a better description. How hard would it be to build on top of what we already have for HM?
I have taken a bit of a break from it due to some other commitments. @rtfeldman is working on a better version of what is currently implemented.
My fault in the existing implementation was attempting to do it in a minimal number of passes relative to the rest of a compiler. Pulling it out to a separate pass as the original authors of the Lambda Set paper (Morphic) did makes it quite straightforward. I have a toy compiler (online playground) demonstrating it (sorry if it’s a bit opaque).
The main tip I would have is to do it in a separate pass once you already have already specialized all generic types, assuming you have a specializing compiler. At that point, run HM-style inference to determine how function calls are propagated in a program (this is actually equivalent to inferring polymorphic variants via the method described above). If you don’t have a specializing compiler, you give up being able to specialize all call sites but you can run the inference pass in or out of band to specialize calls that are known to be direct.
One last thing to note is that the lambda set-style stuff is disjoint from the type system, since lambda sets are directly encoding values, and not types per-se. So while you can reuse code between the two, fundamentally there is no reason to tie the type system to function-call specialization.
Sorry if this is still a bit opaque, happy to dive into more details as needed (here or in DM)!
We don’t have specialization right now because all we have is run-time type checks :) With the addition of HM inference we begin the specialization journey. So
id = x -> xis still one IR node and one C function right now.If we tack on the lambda set stuff, where do you think we should make cut points to specialize or at least change indirect calls to direct calls? We only have two passes right now (infer and C codegen) but would be interesting to add another before codegen. Maybe this will force us to create an actual useful IR.
EDIT: The other day I was also wondering if it was a good idea (possible?) do even do normal worklist call graph / CFA, just to see something working. Maybe it could be useful to additionally filter/refine by type but ¯\_(ツ)_/¯
if you tack on the lambda set stuff you would need to do an inference pass before codegen, but then you can codegen the direct calls in-line (the inference is sufficient to direct how calls should be transformed). I don’t think you necessarily need to have an intermediate IR unless you want to for other reasons too.
yeah, lambda sets is CFA with a unification-based approach.
What is Lambda Set?
https://dl.acm.org/doi/abs/10.1145/3591260
Roughly, it’s a collection of possible function (ie lambda/closure) values you might expect in a given place (eg variable containing a function with a given signature).
By tracking this precisely the compiler (as an optimization) can store the various closures in a variant/sum type like data structure, enabling stack allocation of closures, inlining, etc. This can be faster than using function pointers and allocating every closure on the heap (another popular way due to simplicity of implementation).
Given most lambda variables would have a single concrete definition, it makes it simple for the compiler to eg inline the function call.
Different languages approach the problem in different ways (eg zig allows concrete function values or else function pointers, julia assigns a nominal type per function or closure but also allows for function pointers, etc), but this is another way which I’ve read about being applied to newer languages with ML-like type systems since the closures can just be treated like polymorphic variants and you get optimization benefits flowing pretty straightforwardly.
If you’d like an explanation in podcast form, @hafiz and I talked about it on this episode: https://pod.link/1602572955/episode/82423b3eae6782b9fc47381241d24dd9
Unpopular opinion: I agree with Chris Lattner that constraint generation/resolution based type checkers are a dead end in terms of UX. What he calls context-insensitive type checkers result in better error messages and faster compilation.
From what I understand this is more an issue with the design of Swift’s type system, i.e. the combination of subtyping, ad-hoc overloading (based on type, including return type polymorphism) and global type inference. This is well known to be a problem if you are familiar with the research on type checking, both for performance and usability, and there are approaches to deal with it, and to avoid the unrestricted mixing of problematic features as is seen in Swift.
You can have fast type inference, but it does mean designing your type system with that in mind. Adding in bidirectional typing to direct the flow of information through the type checker can be great for usability and performance too, giving type errors closer to the source of the problem based on annotations provided by the programmer.
Also note that Hindley Milner does not require generating and collecting constraints, you can also perform unification destructively as you go, which greatly improves performance (at the expense of biasing your type errors based on location. edit: looking at the blog post this is actually the approach it takes).
I asked Chris about this and he clarified that he meant that specifically in the context of Swift, not for languages in general. Here’s a recording where he clarified what he meant by that:
https://www.youtube.com/watch?v=ENviIxDTmUA&t=4379s
Also, the language that set the bar for high-quality error messages is Elm, which uses Hindley-Milner type inference and has a very fast compiler, and the Rust team cited Elm as the inspiration for the overhaul of their error messages which gave Rust a reputation for nice error messages:
https://blog.rust-lang.org/2016/08/10/Shape-of-errors-to-come.html
Oh, that’s a much better, nuanced clarification! Pretty much addresses all the stuff in my comment.
Thanks for clarifying it. I would be curious to see the Reddit/HN comment (didn’t find it myself).
Let me give you an example. Take this Rust code. The error message it produces:
To me, the error message is hardly nice. I know what’s going on here:
tokio::sync::RwLock::write()borrowsctx.fieldfor lifetime'athat’s limited toasync_fn()scope. Whereastokio::task::spawn_blocking()takes a closure which is'staticand thus all lifetimes within the closure must be'staticas well. A type error.Now, look at how the explanation differs from the error message itself. There’s multiple problems here:
borrowed value does not live long enoughpoints toctx.fieldbut there’s no borrowing (&) visible at this point in code. The actual borrowing is elsewhere — it’s thewrite(&self). It should show up in the error message IMO.argument requires that ctx.field is borrowed for 'staticdoesn’t say, nor points to what “argument” exactly. There’s multiple arguments within the body of that function so it’s imprecise.Oh I just brought up the Rust error messages as an example of a non-HM language that overall has a reputation for nice error messages (your mileage may vary, but the common view is that Rust’s error message quality is among the best) and that its error messages were inspired by a HM language.
[Comment removed by author]
That’s not a common view at all. Rust survey results indicate only around 2% of respondents are content with current error messages: https://blog.rust-lang.org/2024/02/19/2023-Rust-Annual-Survey-2023-results.html
Respondents aren’t content with the current error messages, but those error messages are a damn sight better than most of the competition! See how a recent post advises users into letting the compiler guide them: https://steveklabnik.com/writing/when-should-i-use-string-vs-str/. Compare that to TypeScript’s errors, which are unhelpful at best.
I’ll agree that Rust has great error messages until it doesn’t, and the situations where it doesn’t are usually the most annoying parts of the language like async, which I think is one of those situations and the example you highlight, so I’d also vote that error messages need work but would still think they are nice.
I speculate that “has a reputation for nice error messages” has an unsaid qualification “among programming language designers who appreciate the complexity of the problem”.
Both of these can be true at the same time:
I don’t have any hard data to back up the claim that it’s a common view, but I just asked Perplexity “what programming languages have the nicest compiler error messages?” and its response listed Rust first, Elm second, and “TypeScript and Go” third.
https://www.perplexity.ai/search/what-programming-languages-hav-OQacQ8BwTKi4bdKvaTwRKA#0
Maybe I don’t understand what context-insensitive means here, but I just want to point out that if you infer types of variables without looking at the uses and you have subtyping, you can turn non-breaking changes into breaking changes. Example: https://gist.github.com/osa1/b17e09f84c33c0d478640e6d7811ed37.
This actually happened to me in real code and I had to revert a change.
If the type checker took the uses of the variable
xinto account this would work as expected.You could not do type inference and ask the programmer to annotate variables with types, but that can easily get tedious, in most cases the type will be obvious, so that’s also not ideal.
Me too.
One thing the resonated with me was “the behaviour of dynamically typed languages makes sense to users”. I heard a similar thing firsthand from one of the creators of Julia. Perhaps not an unpopular opinion but one not spoken openly about enough.
To me, the fact that backtracking inference affects the program behaviour (by eg casting values) confuses the hell out of me. (The latest example being me learning rust - all the auto casting of various refs lead me to learn the wrong behavior…).
i never got around to reading it, but i believe it is this https://files.catbox.moe/008px4.pdf
Thank you! I will look and link
Ipso has polymorphic records and variants. Unfortunately I don’t have a recommendation for a condensed learning resource. I just read Daan Leijen’s papers dozens of times and implemented the ideas in a few different projects.
The Mastodon thread has come to Lobsters. :)
I need to get back into compiler writing. I’ve been so busy with work, I’ve done basically no recreational coding for months now.
You are looking for a reference to Hindley’s original contribution. Is this it?
I can’t check because I don’t have online access to this paper.
~Moonchild found a link to it