lobsters score: 47 · tags: plt
I don’t think this approach requires introducing new keywords for all these operations. You could instead add an
inferkeyword for type parameters that can always be inferred from the types of the function arguments. Somapwould change from:map : ∀(a b : Type) → (a → b) → List a → List binto:
map : ∀(infer a : Type, infer b : Type) → (a → b) → List a → List bWhen calling
map, you omit the typesaandband they’re instead inferred from the type of the first argument(a →b).This gives a more reasonable set of limitations:
- It doesn’t play well with automatic currying, since a curried argument might be required to infer some type parameters. (So the
maptype signature above is a little misleading, it shouldn’t have nested arrows.) - You can only
infertype parameters that are fully determined by the function arguments. Puttinginferon a type parameter that cannot be so inferred would give a type error on the function definition. Instead you would omit theinfer, and the caller will have to specify the type parameters explicity. But these are more complex functions so it’s more reasonable to require annotations on them.
score 8 · canonical
Does this model infer nicely if you have an
a -> bin the first argument? I’m thinking in particular if the passed in argument has infer fields. I suppose that you could have a syntax for explicitly passing inaandbin some cases to help the checker monomorphize properly…
score 2 · canonical
Yeah, I think that works fine. Say you have the type signatures:
map : ∀(infer a : Type, infer b : Type) → (a → b) → List a → List b make-adder : ∀(infer c: Type, a implements Plus) → c -> (c → c) make-adder x = lambda (y: a) x + yand you call:
map (make-adder 10) [1, 2, 3] // evaluates to [11, 12, 13]First you infer
c = Intbecause the argument3has typeInt, then you infera = Int, b = Intbecause(make-adder 10)has typeInt -> Int. Note that we’re not doing general unification here: all of these inferences are always “type variables = concrete types”. You can never get a constraint of the forma = blike you do with unification.More detail about how I imagine this all working:
- At every use site of a variable, that variable’s type is known.
- Therefore, arguments to lambdas and functions require type annotations. Unless, as the blog post suggests, there’s already an expected type for the lambda that comes from the context it’s placed in. (E.g. if you pass a lambda to a function, and that function requires it to have a fixed type (no type vars), you can omit the types of the arguments to the lambda.)
- The return value of a function or lambda can be inferred by type checking its body, though I think we’ve correctly settled as a community on the requirement that function return types must be annotated (though not lambda return types).
- Given all of the above, you can nearly always tell the type of an expression given only the types of the variables that appear in it. The exception is, as just mentioned, that if the context an expression is in requires it to have a certain type directly (nothing fancy like “it must have the same type as that other argument”), then that required type can be used to disambiguate otherwise ambiguous types (like a lambda not annotating the types of its arguments).
score 3 · canonical
- It doesn’t play well with automatic currying, since a curried argument might be required to infer some type parameters. (So the
What I’m wondering here, is how many rules would go away if instead of giving up on unification altogether, we stuck to local type inference? That would eliminate fewer rules for sure, but how few exactly?
There’s also the question of where the bloat comes from exactly: is it from unification alone, or is it from how unification interacts with everything else? If it’s the latter, then keeping “everything else” small could very well help us keeping a small type system. Personally I wouldn’t ask for much: basic built-in types, sum & product types, a built in array if singly linked lists aren’t enough… and not much else to be honest. Type classes maybe, existential quantification perhaps, and I’d skip inheritance for sure.
score 4 · canonical
For what it’s worth, unification should not be difficult to implement; a bare-bones unification monad without backtracking should take fewer than 100 lines of Haskell, using the µKanren recipe. The type-checker can then be written as type judgements, taking one or two lines for each production. I say this because I don’t like to think of folks having to maintain thousands of lines of excess compiler infrastructure.
For example, consider that Haskell is terser than RPython. In my implementation of Cammy, it takes 148 lines to implement the µKanren core (
sampler/cammylib/types.py) and about another 126 lines to type-check 39 productions (sampler/cammylib/arrows.py), without macros. Haskell is also terser than Scheme. The module implementing relational type-checking (allowing type-driven program synthesis) is 289 lines (movelist/cammyo.scm), of which 133 lines are type-checking and the rest are evaluation.
score 10 · canonical
One frustration I’ve had with using a unification monad is having to open up the datatypes to make room for logic variables.
For example, if my language is:
type Name = String data Expr = Const Int | Var Name | Lam Name Expr | App Expr Expr data Type = TInt | TFun Type Typeand I want to use unification, I have to add a case for logic variables:
data Type = TInt | TFun Type Type | TVar LogicVarwhich is unsatisfying because
LogicVarisn’t part of my language; it’s part of the typechecking machinery.I’ve also seen this pattern recommended (from unification-fd probably), using open recursion to keep the logic variables separate:
data TypeF a = TInt | TFun a a type Type = Fix TypeF type TypecheckerType = UTerm TypeFbut I didn’t really like this either… I think because the
Fixwrapper added noise:-- before example :: Type example = TFun TInt (TFun TInt TInt) -- after example :: Type example = Fix (TFun (Fix TInt) (Fix (TFun (Fix TInt) (Fix TInt))))So if avoiding unification means I don’t need a representation for partially-fleshed-out types, that still seems appealing.
What’s your opinion or recommendation on this? For Cammy, maybe it wasn’t a problem because the logic programming is embedded in a dynamically typed language?
score 3 · canonical
This is a great example! It’s relevant to my work too: I have an RPython implementation that I want to make generic, so that other interpreters can use it, but it’s too tightly tied to a single interpreter’s classes.
So, Najd & Peyton Jones call this the “tree decoration problem.” Great name! Their attempt at solving it is called Trees that Grow and there’s a plan for implementing it in GHC. A closely-related problem, the “AST typing problem,” shows up here and has discussion at Lambda The Ultimate, from Yang, and from McKenna; note that we aren’t putting types on ASTs, but putting types on logic variables which are “interpreted” by generic traversals.
I cheat the problem in multiple ways. In RPython for Monte, there are no types; instead, objects guard each other, and are referred to with generic fat pointers to objects. In RPython for Cammy, I hand-wrote a specialized µKanren which only works for Cammy’s type grammar, and I monomorphize when emitting bytecode. In CHICKEN Scheme for Cammy, I take full advantage of CHICKEN’s flexible encoding to intermix logic variables and AST fragments with user-level values.
score 3 · canonical
I think the interesting core here is that if you drop unification you still can express a lot, and it doesn’t prevent you from showing up with unification later.
From a practical perspective it’s a nice trick, I think. Though the microKanren paper looks super interesting, and I’ll definitely be trying to work through it.
score 2 · canonical
IIUC the OP was referring to how the size of a typechecker scales with language size (# of syntactic constructs), rather than the size of a minimal implementation.
score 1 · canonical
I assumed that as well. For comparison, Grace appears to have 15 productions in the concrete syntax, so I would hope that it only takes about 60 lines of code to type-check it; using Pareto-style reasoning, maybe 120 lines at most.
score 1 · canonical
There’s a related problem, which is how unification interacts with type errors. If you’re doing pure unification, you realize at some arbitrary point in time that
a = Maybe Intbut alsoa = Maybe String. Where did these come from? Which one is wrong? If one is from a type signature and the other is from a value, then the type error should be at the location of the value, because it didn’t have the expected type. They could also both be from type signatures, or both from values. How is this provenance information tracked? What do you do when the type mismatch came from a chain of reasoning 7 steps long each at different source locations? What happens when there are higher-kinded types – I don’t even know what questions to ask in that case, but imagine they’re tricksy questions.I always give type signatures to functions in Haskell, in part to improve the error messages. And I imagine it’s not trivial for its error messages to be as good as they are, given that it’s doing unification.
score 8 · canonical
Yeah, ergonomics is important. For what it’s worth, it should be easy to extend the linked recipe to add annotations to logic variables, such that we get a list of annotations upon (dis)unification; this is like turning the logic monad into a union-find/disjoint-set monad. I haven’t seen anybody actually do this, though.
score 3 · canonical
This is a lot of extra steps for just writing code in System F, or one of its numerous variants, aka GHC’s Core. System F is only type checked, there’s no inference. You don’t need to play complex games with having keywords or anything like that. Exactly what you need to annotate is well defined.
score 2 · canonical
Author here: one of my projects (the Dhall configuration language) does exactly that
score 3 · canonical
Props to them for rethinking the assumptions implicit in the technology! And more props to them for actually being able to comprehend the “Complete And Easy…” paper, which has in my experience been described as neither of those things by most people who’ve read it.
It’s entertaining that the proposed solution is to just special-case a bunch of compiler-defined functions, since that’s exactly how Pascal-family languages handled most things that would otherwise require generics. (And to a lesser extent C-family languages, with things like
sizeof().) And of course basically how Go did it for a long time too. So, like, it works, it works pretty well! Sure it kinda sucks sometimes, but it doesn’t prevent you from writing anything you need done. Unfortunately there is a certain type of person, such as myself, who absolutely hates the idea of making built-in functions with interfaces that the compiler is allowed to define but the user is not.However, a thought occurs to me when they outline the steps necessary for this:
- add a bunch of built-in functions…
- add special type-checking rules for those built-in functions when they’re fully saturated with all of their arguments…
- still require explicit type abstraction when these built-in functions are not fully saturated…
…is there any particular reason you can’t make these rules apply to user-defined functions anyway? I mean, start with a pluggable system that lets people define their own judgements into the type checker, the same way that Haskell’s operator overloading plugs new functions into the parser. Then realize that the type signature of a function is just a shortcut for doing that already? I thiiiiiiiink the cost of this is that type information won’t propagate outside the function, so if you have
fn foo(T) -> T; let x = foo(3i32); let y = x + whatever;then the type checker won’t know thatxis actually ani32. At least in some cases. I dunno, I’m bad at this stuff.(…wait, there’s actually a link at the end describing someone else doing exactly that. Welp!)
The example of
if/elseblocks is a good case, since in most languages with lambdas and a good optimizer there’s zero reason forifto be a builtin rather than a function, as far as the language cares. The real reason it’s a keyword is not for type checking but because it delays evaluation, and doing that by hand with explicit zero-argument lambdas all the time feels like unnecessary work unless the language makes it really simple. It works just fine though; see Smalltalk, many Forth-y things, etc.… and if the user were to ask you “How do I define a twice synonym for replicate 2” you would just tell them “Don’t do that. Use replicate 2 directly.”
Yeah, this is entirely practical and very much the cause of the Feels Bad Man™ vibes that make me hate this idea. :-P I would expect this sort of language to very rapidly grow a macro system to paper over these holes, and then you’d spend a lot of your time programming in untyped macros. Kiss the good error messages and coherent semantics goodbye!
I think this “keyword type checking” can potentially occupy a “low tech” point in the type checking design space for functional programming languages that need to have efficient and compact implementations (e.g. for ease of embedding). Also, this can potentially provide a stop-gap solution for novice language implementers that want some sort of a type system but they’re not willing to commit to implementing a unification-based type system.
Amen! <3
score 16 · canonical
Regarding the
foo(T) -> Texample, I believe the compiler would be able to infer thatxis ani32without unification, whether or notfoowas a built-in function or a user-defined function.In the case of a built-in function, there would be a judgment like:
Γ ⊢ x ⇒ a ────────────── Γ ⊢ foo(x) ⇒ a… and in the case of a user-defined function, the
foofunction would require an explicit type application (e.g.foo<i32>(x)) and that would provide the information necessary to infer the type of the result. Obviously, as the post admits, it’s not ergonomic but it does work (this is basically exactly how Dhall works, and Dhall also does not support unification).I agree with the rest of what you’re saying, though.
score 4 · canonical
Yes, I agree with your sentiment. Also:
I thiiiiiiiink the cost of this is that type information won’t propagate outside the function
And ofc that applies to the function. itself, i.e. what will be the type of the variable that you store this function into? It opens a box of lots of issues, which is why I have the same feeling as you: those exceptional built-in things break my intuition about how things work, which is very annoying.
score 2 · canonical
