I would say F#. It has most things you could want:
- Proper functional programming support
- Fast enough runtime
- Cross-platform and open-source
- Mainstream ecosystem
- Large corporate backer
- Compile to JS
- Commercial and open-source tooling options
… and yet C# is far more popular. This is because language adoption is driven by existing user base and other network effects NOT the quality of the language itself.
Erlang is underutilized at least in part due to its odd syntax, despite being a powerful tool. Elixir is helping address that, although I much prefer Erlang’s syntax.
If you have two types, there should be a single type that "joins" them, in the sense that you can understand both of the original types as somehow being special cases of the join type.
A join is not necessarily a union, since the representations of the three types might be completely different, and also because the third type might contain many values that don't correspond to anything in the two original types. (It might be much bigger than the union.)
Mathematical lattices must also have "meets", which are like joins except down instead of up. I'm not sure that meets are as important as joins in this context.
It refers to https://en.m.wikipedia.org/wiki/Lattice_(order), with the elements of the lattice being the arithmetic types and the order relation being the subtyping relation here. Given any two types in the lattice, the lattice property then guarantees that there exists a unique common (least) supertype (aka upper bound, supremum) of the two types. Which means you can apply the binary operation (e.g. addition) as defined for that common supertype.
I still actively use SML - mlton or smlnj usually, polyml too. I’m aware of the issues raised in this post but haven’t ever found them to be a source of much headache. To be honest, the biggest headache is moving between compilers and their different build processes. Other than that, the fact that the language isn’t really changing is a big attraction for me.
Mostly programming languages related: code analysis, compilers, code generators, and formal verification tools. Outside PL work, I’ve used it to build some modeling and simulation tools. I also use it for misc. little tools that I need every so often where I want the type system to help me out. For a brief few years (‘09-‘15) Haskell played this role for me but I went back to SML after getting frustrated with various parts of that ecosystem.
I mentioned to Mark when I saw this, and he noted it in the addendum at the end, that calling Standard ML dead is a bit too much. I've written recently [0] about how active it surprisingly is.
I also disagree that its failure to "succeed" has anything to do with syntax or semantics and solely that it doesn't have a Jane Street or any company publicly behind it.
I don’t think it succeeded or failed. Languages don’t need to be wildly popular in industry to be valuable. I personally like that industry ignores SML. Even for Ocaml I don’t use the Jane street core: I just use the stock language since it’s small and stable. I like that Jane street helps improve the core compiler though.
I would like to point out that the article does not use the words “succeed” or “fail”, and makes no claim about whether SML “succeeded” or “failed”. Indeed I don't think that is a useful question to ask.
My article was trying to address a much less nebulous issue: what problems did I personally see with SML in the mid 1990s that let me to abandon it at that time.
I do wonder if differences in language governance models impact uptake. Being completely biased (having worked on the SML/NJ compiler a long while ago), I very badly want to see more adoption. There is definitely continuing activity with new (IFL award winning) LLVM backend work [1] and Manticore.
> Haskell's primary solution to this is to burn it all to the ground. Mutation doesn't cause any type problems because there isn't any.
This is true, but I think it is also misleading. Haskell has the same problem if you use unsafePerformIO to create a polymorphic IORef at top level. You can then use this IORef to subvert the type system. I think this is something many Haskell programmers are not fully aware of: unsafePerformIO doesn't just break referential transparency; it can also fundamentally break memory safety. Now, you may say that unsafePerformIO is obviously unsafe (it's in the name!) and should never be used. But if you look at many foundational Haskell libraries, you will find that they use unsafePerformIO or similar functions internally, usually for performance reasons. What are the rules that govern safe usage of unsafePerformIO? As far as I can determine, these rules are basically just GHC implementation details, and people often get them wrong. And if you break these rules, you don't just get a function that doesn't do what you expected - you may have subverted memory safety entirely.
I think this is an interesting conundrum. Haskell makes much stronger promises than SML, but if you break the rules, all bets are completely off.
IMHO Haskell's lazy evaluation has some significant disadvantages compared to SML's strict evaluation. In particular, lazy evaluation makes it difficult to find the performance bottlenecks in a particular piece of code or to determine the time complexity of an algorithm just by reading it.
Furthermore, subtle changes in how a function is written (for instance, making a multiplication function not evaluate the right operand if the left operand is zero) can cause wildly unexpected performance changes in that function's callers. In effect, the performance of a function is no longer just determined by that function's structure and by the function calls it contains; performance of one function now depends heavily on the implementation details of others and the context in which that function is used.
Granted, any optimizing compiler can have this effect, but it's rarely noticeable in strictly-evaluated languages, where at least to some extent the order of evaluation must correspond to the structure of the code.
It would be nice to have a language that could treat both strict and lazy evaluation as equally first-class, as opposed to having one be the default (whether "strict" as in ML or "lazy" as in Haskell) and the other only being expressed by syntactical kludges. This may well be possible by relying on logically-inspired features like polarity and focusing, and endowing data types with strict or lazy "natural" polarities.
It's the opposite, no? Basically every lazy language has some way to force evaluation, but in most strict languages laziness is never fully supported. Closest I can think of is a Scheme with continuations, but even then it takes some extra work. I know in CL even primitive laziness takes a lot of work and a code-walker, and whatever it's other flaws Common Lisp is at least adaptable.
I could be totally wrong, happy to be corrected. And I'm less familiar with lazy languages. My very basic impression of implementing laziness is just that you wrap all of your data structures/algorithms in functions that must themselves be called. Like everything is an iterator.
Wrapping in bare functions gives you “call by name”, where
let y = f(x) in g(y, y)
will compute f(x) twice. In effect you just evaluate function applications (and lets) by substituting a copy(!) of the argument text for every occurence of the variable in the body. It’s pretty easy for this to get exponentially bad.
If you want conventional lazy evaluation, that is “call by need”, you need to memoize: wrap everything in stateful functions (“thunks” in Haskell-implementer-speak, “promises” in Scheme-speak, but not the same as E/JavaScript-style async promises) that compute the result and save it on the first invocation, but then return it immediately. Apart from efficiency considerations (it’s better to use a tagged union of function pointer and result value instead of calling the function all the time) and a single conceptual wrinkle[1], that’s it.
Call-by-name and call-by-need obviously yield very different complexity, but are in fact equivalent regarding termination, and optimal: if any evaluation strategy terminates on a given term, so do they. The lovely recent paper “Call-by-need is clairvoyant call-by-value”[2] shows that lazy evaluation in fact never does more steps than call-by-need and in fact does a subset of them: those and only those that influence the final result. (The problem, of course, is that none of this addresses memory usage.)
Emulating eager evaluation with lazy can be done if you have seq: (a `seq` b) means “when this is forced, force (the outermost layer of) a, then replace yourself with b (which will then be forced as much as necessary)”: replacing f(e) with (let x = e in x `seq` f(x)) everywhere is not quite enough IIRC, but gets you most of the way there. In a dynamically-typed language, you can implement seq using some of the built-in strictness, like
a `seq` b = if null(a) then b else b,
because null (or any other type discriminator) needs to force the outer layer of its argument (though I’m not sure if you can make this work in bare lambda calculus with functions only, oops); in a statically typed language, a polymorphic seq is AFAIU an unimplementable primitive, but if you need to translate a complete program you should always be able to implement seqT for every type T that occurs in it and use that (and every lazy language has seq anyway).
In strict languages, you can delay computation by wrapping it in a zero-argument lambda -- i.e., a "thunk." For efficiency, you want to memoize thunks (that's what Haskell does[1]) so that they only ever evaluate once. Scheme has the "delay" operator to create memoized thunks, which you can later "force". It is true that these are not first class in the sense that you need to manually force the computation, but if it were automatic, how would you (efficiently) pass un-evaluated values around?
[1] Incidentally, this is why Haskell's garbage collector needs to be able to deal with mutation. A thunk might graduate to an older generation, and once it is finally evaluated it can end up having pointers to the nursery or other younger generations.
So I had a bit of fun implementing something like Haskell’s “Validation” in an eager language recently that has coloured my take somewhat. Basically “perform all these computations and tell me all the things that were wrong with my inputs” is way easier to express in a default lazy language than a default eager language. In default eager you’re constantly trying to figure out the largest number of operations you can do before can no longer continue.
Yes, there’s weird perf things that can bite you, but there’s also a bunch of regular bread-and-butter coding things that pervasive makes very much easier.
You really can simulate laziness in a strict language at the small cost of wrapping things in lambdas yourself -- you don't have to figure out what operations you can do yourself so long as you make everything that should be deferred deferrable (for example, if you're trying to do monadic fixedpoints you need to be careful). If you create your own lazy data structures, if your language supports it you can even have those thunks be forced for you automatically. For example, lazy streams (infinite lists) in Python with memoized thunks:
class Thunk:
def __init__(self, f):
self.evaluated = False
self.val = f
def __call__(self):
if not self.evaluated:
self.val = self.val()
self.evaluated = True
return self.val
class Cons:
def __init__(self, x, xs):
assert isinstance(xs, Thunk)
self.head = x
self._tail = xs
@property
def tail(self):
return self._tail()
def nth(self, n):
for _ in range(n):
self = self.tail
return self.head
ones = Cons(1, Thunk(lambda: ones))
print([ones.nth(i) for i in range(10)])
# [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
def lazy_map(xs, f):
return Cons(f(xs.head), Thunk(lambda: lazy_map(xs.tail, f)))
nats = Cons(0, Thunk(lambda: lazy_map(nats, lambda x: x + 1)))
print([nats.nth(i) for i in range(10)])
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
def lazy_zip(xs, ys, f):
return Cons(f(xs.head, ys.head), Thunk(lambda: lazy_zip(xs.tail, ys.tail, f)))
fibs = Cons(0, Thunk(lambda: Cons(1, Thunk(lambda: lazy_zip(fibs, fibs.tail, lambda x, y: x + y)))))
print([fibs.nth(i) for i in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
I think laziness is pretty cool, but it does mix up two notions that turn out to be individually important: data and codata. Mixing them together makes a language's type system logically inconsistent, in the sense that you can't use Curry-Howard isomorphism anymore (nonempty types <-> true propositions). Data is, essentially, anything you can do structural recursion on and evaluate in finite time, no matter the evaluation strategy. Codata is fuzzier to me, but the canonical example of codata is the lambda abstraction. Haskell smears a layer of codata over all its algebraic datatypes (data) to make everything lazy.
I once took advantage of this in a light way when designing a compiler targeting C in Haskell, with a goal of making beautiful-ish C code. The language, unlike C, was expression-based, so everything could evaluate to a value. The step that lowered expressions into C syntax returned a struct with multiple fields, each giving a piece of C syntax depending on how the expression was going to be used in context -- was the value of the expression going to be used? or just its side-effect? Then, due to laziness, only one of the fields of the struct would actually be evaluated. (It also handled other cases: lvalues and whether the expression's value was going to be immediately stored somewhere, since then the expression could use that location directly rather than creating a temporary variable if it might have needed one.)
They are both Turing complete, they can implement each other.
But, the effort to implement call-by-need evaluation (graph rewriting or remembrance of computed value) is higher in strict languages than (sparse) addition of annotations in lazy ones.
I'd like to see more use of things that are more powerful than evaluation. For instance, in mathematical functions you can differentiate them, or invert them, and both of those are obviously useful in mathematical "metaprogramming".
The only PL example I can think of is unification, which logic programming has as well as evaluation.
Without extensions you can do it per-case via `seq`, see https://wiki.haskell.org/Seq (although it might get tricky because you might have to do it repeatedly because it only performs one “level" of evaluation)
There is also another GHC extension called bang patterns to request strictness on pattern matches.
In general both lazy and strict languages can be strict or lazy. But the way it shows in the data types is different. A strict language can defer evaluation by adding a level of abstraction (instead of type `X` you have `() -> X`). A lazy language can force evaluation (it will have to evaluate at some point anyway) but you might not see it in the type. I’m not sure which one is better to be honest. Being able to infer laziness/strictness from the type is nice, but it also hinders interoperability.
SML is one of my favorite languages (I've been (very) slowly writing a compiler & language server for it).
Sure, it has some warts/differences compared to newer languages - we have moved towards traits/typeclasses/etc, and I wish I could just write #[derive(Debug) - but I feel that SML fits in a very unique spot for programming languages. It's extremely simple, yet still powerful and expressive. I hope we will see continued work on SML/Successor ML descendants (like 1ML, etc), because I think there's still potential there
I think some updated language tooling would dramatically help.
I have the compiler on GitHub [1] - I just started working on it again after a 2-year hiatus (to finish my PhD).
As for language-server, I'm currently sketching out some plans to use SMLnj's "Visible Compiler" feature, since that seems the easiest path forward. I have a half-baked language-server based on MLton's def-use output, but it's too unstable to share. I am planning to make some progress on the language-server in the next couple weeks.
SML was the First Language used for the Computer Science degree I took. I felt at that time, and continue to feel years later (that degree course now teaches Java as First Language) that this was a good decision despite the fact that most graduates don't end up using SML to write anything.
In the course of my education I experienced some things which I'm convinced are a bad idea even though they worked out OK for me such as selective education (whole schools only for "talented" children) and single sex secondary education, but SML as First Language is not one of those things. It worked well for me and I'm convinced it's a good idea even though I would not advocate writing new real world projects in SML unless you've got some very particular reason.
I feel like there should be some expected order. Like BASIC->functional->scheme or something. Maybe switch them around, or throw in Python. There are a bunch of hurdles for new programmers to jump over, and it seems like maybe the best way to get them over them is with a different language for each. When my roommate took his intro CS course in Python he was mystified by type errors since they're often somewhat hidden. In Java the next semester the boilerplate made the whole program feel less understandable, but a semester or two later and it could have taught valuable lessons.
Interestingly we used OCaml in the first semester (by the professor's choice) in late 2003, and I hated it and failed the course. A year later I retook the course and as it was some other professor's turn it was SML, which I kinda liked. I did revisit OCaml more than 10 years later and actually enjoyed it, so maybe it was a bit of "wrong place, wrong time" but as I heard from some users it should have evolved quite a bit in those 10 years. (I am 95% sure it was not functional programming per se, but I never figured out what exactly was the culprit, I guess a dose of "I know how to code" was involved).
There was a history of Standard ML published as
part of HOPL4 a couple of years ago - https://dl.acm.org/doi/10.1145/3386336
I was particularly interested by the more recent history, after SML '97, where there was plenty of interest in improving the language and its
library, but Milner insisted that there would be no further changes, and the Definition was non-free so the others could not build on it. (It has since become Free though its source code was lost.)
Despite that there is/was an SML Basis Library project, and a Successor ML project, but it looks like even the die hard fans have drifted away
https://smlfamily.github.io/
I'm not really convinced by the author's first example. While an element of type bool is an instance of type a, an element of type bool -> bool is not an instance of type a -> a.
The issue is precisely an issue of variance, which is mentioned in reference to Scala, but somehow it's glossed over. The type a -> a is covariant in its second argument, but contravariant in its first argument. As a result, you cannot "specialize" it to bool -> bool, because specialization is really another name for covariance.
Another name for contravariance is "generalization". If you ask for something of type "bool -> b", and someone provides you with some function f of type "a -> b", then you're happy. The map f is indeed an instance of "bool -> b": it can eat anything, so it can eat booleans. But with type a -> a, you cannot do what I just did: type "a" is already the most general one, and you cannot do better.
If SML accepted something of type "bool -> bool" for an instance of type "a -> a", then it was a fundamental error. But this doesn't mean that the whole thing should have been thrown out and replaced with monads. In fact, I don't really get how monads have anything to do with the problem at hand. If Haskell can prevent the following code, then I don't see why SML couldn't have prevented the authors' code:
This is pretty fundamental to the Hindley-Milner system of parametric polymorphism (which I believe was the first such system?). The 'a is treated as a universally quantified type variable that can be unified with concrete types [1]. 'a=int and 'a=bool are two possible ways to unify the type variable, yielding int->int and bool->bool as signatures that unify with 'a->'a. Similarly 'a list -> 'a means a function that takes a list of some type, and returns an element of the same type (the built-in List.hd function is an example), while 'a list -> 'b is a looser type signature meaning "takes a list of some type, and returns some value, not necessarily of the same type", so int list -> int and int list -> bool would both fit the 2nd schema.
This is all sound if the language is pure, which ML originally was.
There's also a relevant discussion at Stackoverflow. [2]
SML doesn't have subtyping or variance AFAIK so that can't really explain the issue. The way I like to look at it is to translate to System F. The standard translation would give:
let m : ∀α. ref (α → α) = Λα. ref [α → α] (λx. x)
in
(m [bool]) := not;
print ((!(m [int])) 23)
end
This actually has different behavior because the ref allocation is under a big lambda (Λ). Each time it is applied it would generate a new ref cell. So it would generate a `ref (bool → bool)` starting with `λx. x` and assign `not` to it. Then generate a separate `ref (int → int)` (again starting with `λx. x`) and dereference and apply it. Thus this would print `23`.
(The naive model of) SML runs into problems because it erases the big lambdas and so evaluates `Λα. ref [α → α] (λx. x)` to a single ref cell of type `∀α. ref (α → α)`
Sidenote: Variance does have some relation here, I couldn't think of a way to trigger bad behavior without a type that uses the type variable invariantly.
The point AFAICT isn't that the type checker sees "bool -> bool" as an instance of "a -> a". Instead, the issue is that during type inference, when the compiler sees "m := not", it will infer that a == bool, i.e. that "m = ref (fn x ⇒ x)" was really "m = ref (fn (x:bool) ⇒ x)".
But then it will forget that inference when type checking "print ((!m) 23)"; there it will infer that a == int, and that the original definition of m was really "m = ref (fn (x:int) ⇒ x)".
Essentially the issue is that type inference forgets that it already solved for "a" and can't assign it to both "int" and "bool".
As I explained in https://news.ycombinator.com/item?id=31222098, I don't think this explanation is correct. You're right that bool → bool is not a subtype of α → α, and that function types are contravariant on their argument type. But because function types are not also covariant on their return type, the same logic would tell us that α → α is neither a subtype of bool → bool nor a supertype.
In fact, though, α → α is a subtype of bool → bool, for reasons that have nothing to do with covariance and contravariance. We can define a function whose argument is of type bool → bool; here in OCaml:
# let abool f = not (f true) ;;
val abool : (bool -> bool) -> bool = <fun>
And we can define a function of type α → α:
# let f = fun x -> x ;;
val f : 'a -> 'a = <fun>
And that function is perfectly acceptable as the argument to abool, or indeed in any other context where we need a function of type bool → bool:
# abool f ;;
- : bool = false
Thus α → α is a subtype of bool → bool, and also int → int, string → string, etc. And this is not because the value restriction has been sneakily applied behind our backs; f and abool still have their original types:
# abool ;;
- : (bool -> bool) -> bool = <fun>
# f ;;
- : 'a -> 'a = <fun>
And we can still apply abool to a function that's unashamedly bool → bool:
# abool (fun x -> not (not x)) ;;
- : bool = false
# not ;;
- : bool -> bool = <fun>
# abool not ;;
- : bool = true
So specialization is not another name for covariance, and you absolutely can specialize α → α to bool → bool.
I wrote, "But because function types are not also covariant on their return type," but that should have been, "But because function types are also covariant on their return type."
All these explanations seem somewhat confused to me because they don’t pin down what the variables represent. In a traditional formulation of Hindley-Milner, there are two distinct notions of a “variable”: (1) a bound variable under a quantifier, or (2) a metavariable, also known as a unification variable, introduced by the type inference algorithm.
Bound variables are variables that either the programmer wrote explicitly in their program or variables introduced by generalization. They appear underneath a quantifier, as in the type
∀ a. a -> a
which makes the quantification explicit. However, both SML and Haskell make the placement of quantifiers implicit by default, which is somewhat syntactically convenient, but it obscures this distinction.
In HM, a bound variable only unifies with itself, so if we use `~` to mean “unifies with”, then `a ~ a` holds but neither `a ~ Bool` nor `a ~ b` do, assuming `a` and `b` are bound variables. However, bound variables do not actually get involved in typechecking unless the programmer wrote them in their program explicitly, because when a polymorphic binding is used, the typechecker instantiates it, replacing bound variables with metavariables.
There is no way for the programmer to write metavariables in their types, because metavariables are, as their name suggests, a metalanguage concept introduced by the typechecking machinery, not a part of the underlying language of types. This makes writing them down in a way that clearly distinguishes them from bound type variables somewhat difficult, so I will adopt the convention of using Greek letters to represent metavariables. This means we can instantiate the above type to get
α -> α
which is a very different type! In particular, while bound variables only unify with themselves, metavariables unify with anything so `α ~ α`, `α ~ Bool`, `α ~ a`, and `α ~ β` all hold. However, a metavariable can only unify with something other than itself once, because the process of unification effectively mutates the typechecking context by globally replacing the metavariable with the type it unified with. That is, if typechecking requires the unification `α ~ Bool`, then our function type `α -> α` becomes `Bool -> Bool`, since we perform the unification by globally replacing α with Bool.
All of this stuff might seem a bit fiddly, since we’re explaining metavariables in terms of internal details of a typechecking algorithm. But indeed, that’s the point: metavariables are an invention of the typechecking algorithm, a mechanism used to implement type inference. They aren’t really types, they’re “holes” in types that have yet to be filled in by the type inference process. So when you ask a question like “is `Bool -> Bool` a subtype of `α -> α`”, your question is somewhat meaningless, as it depends on what α means for whatever typechecking algorithm you’re discussing, and pure HM does not really have any notion of subtyping (just instantiation and unification).
If we introduce more sophisticated type systems that do have subtyping, then we have the machinery to talk about things like covariance and contravariance. But in HM, no such relation exists, so any notions of subtyping, covariance, and contravariance exist only in our heads, not in the type system itself. Still, we can informally establish a notion of subtyping by saying that if S is a subtype of T, written `S <: T`, then an expression of type S can be used wherever an expression of type T is expected. Given that, we can ask whether
(∀ a. a -> a) <: (Bool -> Bool)
holds. The answer is certainly yes, as we can always instantiate the former to get the latter (which is what you refer to as specialization). But that quantifier is crucial, because it’s what allows us to do the instantiation! If we just have
To clarify, when I wrote α, I always and only meant a bound variable under a quantifier, because I didn't know about this metavariable thing at all. I was writing α → α because that's how tuareg-font-lock-symbols renders 'a -> 'a, which does include the implicit quantifier.
It's no wonder I was never able to understand HM since I didn't know about this dual nature of variables. This will probably help me a lot next time I try to understand it! I do think I understand unification, though I've only implemented it once.
I really appreciate you being willing to engage on HN, despite the unpleasant aggressiveness and character assassination that runs rampant here.
> While an element of type bool is an instance of type a, an element of type bool -> bool is not an instance of type a -> a.
This sentence is true if you interpret `a -> a` to mean `∀ a. a -> a`, i.e. a universally-quantified type. But it is false if you interpret it to mean `α -> α` where α is an unsolved metavariable, for the reasons I describe in this comment: https://news.ycombinator.com/item?id=31238081
> The issue is precisely an issue of variance, which is mentioned in reference to Scala, but somehow it's glossed over.
Not so! The issue is the incompatibility of mutable references and value polymorphism, which variance alone does not solve. For example, in Scala, you cannot write something like
val xs[A]: ArrayBuffer[A] = ArrayBuffer[A]()
because if you could do that, then you could write
val bools: ArrayBuffer[Bool] = xs[Bool]
bools += true
val ints: ArrayBuffer[Int] = xs[Int]
ints.last
and all hell breaks loose. Note that variance does not in any way save you here—the type variable `A` is always covariant, so this code is variance-correct. Scala prevents this by only permitting polymorphic functions, so you would have to write the above example like this, instead:
def xs[A](): ArrayBuffer[A] = ArrayBuffer[A]()
Now everything is okay, because if you call this function twice, you get two different buffers. This is precisely what the ML value restriction enforces.
> Another name for contravariance is "generalization".
Maybe this is true in some sense of the word, though I’ll admit I’ve never seen “generalization” used in this way. But in Hindley-Milner type systems, “generalization” is a term of art that means something fairly specific, namely the implicit introduction of universal quantification, so using it in this context to mean something else may be a little confusing.
> If SML accepted something of type "bool -> bool" for an instance of type "a -> a", then it was a fundamental error. But this doesn't mean that the whole thing should have been thrown out and replaced with monads. In fact, I don't really get how monads have anything to do with the problem at hand.
I sort of agree—introducing monadic structure has essentially nothing fundamental to do with this particular problem. However, it is incidentally true that the monadic encoding of mutable state in Haskell sidesteps the problem. To see why, suppose we translate the Scala example from above into Haskell. Suppose we have a constructor like this:
newArrayBuffer :: forall a. IO (ArrayBuffer a)
Note that this is itself a polymorphic value—it isn’t a function! But since it’s wrapped in `IO`, it isn’t itself a polymorphic buffer, just a recipe to create one. If we wanted to trigger the bad behavior, we’d need to be able to create a definition with a type like this:
xs :: forall a. ArrayBuffer a
But that isn’t possible to obtain from `newArrayBuffer`, even though Haskell allows polymorphic values. That’s because, in the type of `newArrayBuffer`, the `forall` is outside the `IO` constructor, so in order to actually use it in a computation using `>>=`, we have to instantiate `a` to some concrete type. In other words, `IO` plays precisely the same role here that a nullary function does in Scala: it ensures each instantiation is generative, i.e. it returns a distinct buffer.
So Haskell doesn’t need a value restriction because, in a sense, everything impure is subject to a value restriction, with `IO` playing the role of the nullary function type, and that includes anything that contains mutable state. But since Haskell relies on this property of `IO` to preserve safety `unsafePerformIO` can subvert the type system. We can write
> In Structure and Interpretation of Computer Programs, Abelson and Sussman describe an arithmetic system in which the arithmetic types form an explicit lattice. Every type comes with a “promotion” function to promote it to a type higher up in the lattice. When values of different types are added, each value is promoted, perhaps repeatedly, until the two values are the same type, which is the lattice join of the two original types. I've never used anything like this and don't know how well it works in practice, but it seems like a plausible approach, one which works the way we usually think about numbers, and understands that it can add a float to a Gaussian integer by construing both of them as complex numbers.
Julia uses this in pretty much all of its math functions and probably elsewhere as well, and it works unbelievably well. The type promotion system makes math Just Work, even (and especially) in the face of different-sized numbers. The result is that 99.9% of the time you simply don't have to think about the types of your numbers. Here are some examples from the docs:
And not only are types promoted, but in well-typed Julia code, the deduction of promotion types happens at compile time instead of runtime, so there is almost no performance cost to this either.
Wouldn’t that last example be incorrect mathematically? For example, if the Int8 was -10 and the UInt16 was 10, what would that casting do? Would a better promotion be Int32 for both? Just curious, Julia is a language I’ve been very interested in using for a long time, just haven’t had the opportunity to sit down and learn yet.
Yeah, it's a little iffy. Finding a sensible set of promotion rules is hard. I don't know the reason (this was decided before I was involved), but if I had to guess, it would be because unsigned types are much less common, so if the user has them, they probably want to keep them for a reason.
This is completely off topic here, but the first promotion of Int64 -> Float64 is also iffy as not every Int64 is exactly representable. The Int64 -> Float16 promotion also looks weird to me because Float16 range is so small
You think negative numbers in a signed type are a 1-in-a-1000 case, really?
Silently casting signed integers to unsigned is a terrible idea, and a recipe for bugs. And completely unnecessary to boot, because you could promote Int8 to Int16, and (Int16, UInt16) to (Int32, Int32).
Related, an integer bug that can't be fixed in C is that "unsigned short" promotes to "int" instead of "unsigned int", so this doesn't produce the result you want.
unsigned x = (unsigned short)65535 * (unsigned short)65535;
(It overflows even though it's less than UINT_MAX, and worse it's UB.)
> Wouldn’t that last example be incorrect mathematically? For example, if the Int8 was -10 and the UInt16 was 10, what would that casting do?
Well....
Signed integer arithmetic and unsigned integer arithmetic do not differ. At all.
The difference between an Int16 and a UInt16 is not in the 16 defined bits. It's in the infinite number of implicit bits representing place values above 2^15. Those bits are always 0 for the UInt16, but they're identical with the high bit of the Int16.
So it's not clear what it would mean for the result type to be "incorrect mathematically". Mathematically, an Int16 and a UInt16 are the same thing.
However, the path by which you promote the values does matter. I don't know what Julia does. But:
One of those should be the result Julia gives. I tend to hope it's the first one. That would correspond to a promotion strategy of "always expand the type to its full width before converting between signed and unsigned". Expansion (and shift, I guess) is the only operation for which the difference between signed and unsigned is relevant.
> Would a better promotion be Int32 for both?
Probably not; that would imply that when you add two UInt16s together, you expect to get a UInt32 (or UInt17...) back.
It took me a couple of readings to make sure I understood what you were saying, thanks for the reply. I think my misunderstanding was from not knowing Julia and thinking about promotion of values as having a permanent effect on the variables used, which isn’t what’s happening. As well, it requires the user to understand what they’re doing when they’re using an operator that uses automatic promotion, and to think about what they want to happen. For example, if Int8 = -10 and UInt16 = 5, and I’m expecting an answer of -5, then I’ll need to be more explicit to get the number I’m looking for. If I’m expecting 65530, then the implicit promotion works fine. Of course, this is no different from any other programming language that allows addition of differing types.
> For example, if Int8 = -10 and UInt16 = 5, and I’m expecting an answer of -5, then I’ll need to be more explicit to get the number I’m looking for. If I’m expecting [65531], then the implicit promotion works fine.
That depends on what you're hoping to do with the number you're looking for. If you wanted -5 as an Int16, the bit pattern would be 1111 1111 1111 1011 or 0xFFFB.
If you wanted 65531 as a UInt16, the bit pattern for that is 1111 1111 1111 1011 or 0xFFFB. You're getting the same result either way. And any values you compute from that result [that is, by arithmetic] are going to be unaffected by whether you labeled the result "Int16" or "UInt16", because that's just a label. If you labeled 0xFFFB a "Snerf", the arithmetic would still be the same.
There are only a very restricted set of places where you need to be explicit about whether you think of your variable as an Int or a UInt:
1. When you're widening it.
2. When you're doing a right shift.
3. When you're formatting it for display to a human.
Yeah, case three was the one I was thinking of, though cases one and two are interesting as well. Number two requires you to think about using an arithmetic or logical right shift, though I'm struggling to think of a situation where you'd be promoting types and then not know what your intent was if you're then doing a right shift. I guess my confusion here is simply related to the idea that if you're getting that deep into binary, you'd probably want to be more explicit about what types you want to promote to, rather than relying on the Julia defaults. It's been a while since I've needed to think deeply about bitwise operations, still just as interesting as I remember.
the main thing Julia loses from this approach is function types. since each function is a user extendable blob of methods, there basically ceases to be a meaningful notion of the type of a function. imo, this is a worthwhile trade-off, but it can sometimes be annoying (and it's why Julia is hard to compile ahead of time. this pattern makes figuring out which methods you need to compile Turing complete)
How about OCaml? I studied it alongside SML and liked it a bit better. Whenever something in SML struck me as a poor design choice, I looked over and saw that Caml had done something about it. (I felt the "O" was a completely unnecessary addition.)
I think that in some sense the correct solution to the reference type problem is the Scala solution, which depends on subtyping. This is pretty tricky to reason through, and adding subtyping makes type inference a lot more difficult, so it's hardly surprising that SML avoided this, but it provides a much more satisfying solution. (OCaml later embraced subtyping, but in the particular case of mutable references, it instead adopted the same value-restriction approach as SML.)
Here's the way I understand it; be warned that I'm just starting to understand this stuff, so I might have got something wrong. I'd welcome corrections.
S is a subtype of T (S <: T) iff you can use an object of type S wherever you need an object of type T. This gives us a partial order on types, in the sense that
∀T: T <: T
∀S:∀T: S <: T ∧ T <: S ⇒ S = T
∀S:∀T:∀U: S <: T ∧ T <: U ⇒ S <: U
which all follow from the above informal definition. Sometimes we make it a lattice, for example by adding a ⊤ type ("top") that everything is a subtype of and a ⊥ type ("bottom") that is a subtype of everything.
In general in the presence of subtyping we can only infer type bounds on most things, not exact types. For example, consider that {3} <: ℤ <: ℝ <: ℂ; if we see a function being applied to the value 3, we can infer that it must be at least applicable to {3}, but a function that is applicable to ℤ or ℝ would also work.
Given this informal definition, surprisingly, α → α (the type of the identity function) is a "subtype" of bool → bool, not vice versa. That is, α → α <: bool → bool. Whenever we want a function of type bool → bool, we can use a function of type α → α, but not vice versa.
α → α is a weird type because it includes an implicit universal quantifier: ∀α: α → α. As it turns out, the judgment above that α → α <: bool → bool is not because α <: bool or bool <: α. It's because bool → bool is what you get when you instantiate the quantifier with a particular value, α = bool.
There's also a function-specific rule for subtyping, and it's a real mindbender: B → C <: A → D if A <: B and C <: D. We say functions' argument types are contravariant and their results are covariant. Considering the A <: B case, if we need a function from integers to booleans, we can use a function from real numbers to booleans—we just won't happen to invoke it with any non-integer real numbers, but it will still work, assuming there aren't incompatible binary representations at play (as in the case of the OCaml +. and + operators MJD mentions.). Considering the C <: D case, we can also use a function from integers to true: it will never return a value we cannot use as a boolean.
We can decompose the operation of taking a reference to x, ref x, into a step of creating a reference r and then applying the reseating operation r := x to it. This operation is valid iff the assignee is of a subtype of the referent type; that is, (r : T ref) := (x : S) is valid iff S <: T. That's because later when we read r we will be using its value as a T, so any subtype of T is fine. So for ref (fn x ⇒ x), we have that S = α → α, so we can derive the type bound that T must be some supertype of α → α. As mentioned above, this includes bool → bool, but it also includes ℤ → ℤ, (ℤ × ℂ) → (ℤ × ℂ), and the polymorphic type α → α itself. So reseating a reference is contravariant: we can write a value we know is an integer or anything more specific (a subtype such as a positive integer) into a reference we know to hold an integer or anything more general (a supertype such as a real number).
The dereferencing operation !r turns out to instead be covariant: !(r: S ref): T is valid iff S <: T. That is, we're going to use...
I don’t think the complaints about evaluation order in this blog post really make sense. The evaluation order of `map` in SML is no more mysterious than the evaluation order of `mapM` in Haskell. The use of explicit monadic sequencing has its advantages (as well as nontrivial disadvantages), but this is not one of them. This is particularly true if `mapM` is written using applicative functors, as the definition
mapM :: Monad m => (a -> m b) -> [a] -> m [b]
mapM f [] = return []
mapM f (x:xs) = (:) <$> f x <*> mapM f xs
is virtually identical in structure to the SML definition
fun map f [] = []
| map f (x :: xs) = op:: (f x) (map f xs)
aside from the “plumbing” of `return`, `<$>`, and `<*>`. Indeed, the whole motivation of applicative functors, as well as the source of their name, was a desire to write code in a form closer to an applicative style, which is to say non-monadic, direct-style code like the SML example. The blog post says
> Does it print the values in forward or reverse order? One could implement it either way.
but obviously this is also true of `mapM`. That would just be a different function. Monadic sequencing does not help with this at all.
Furthermore, the author mentions algebraic effect systems. It isn’t clear to me from the wording if the intent is to offer them as a solution for the shortcomings of monadic encodings or as a nicer way to pin down evaluation order, but the latter is certainly not true—the two are entirely orthogonal. Algebraic effect systems depend on the evaluation order being well-defined by other means to work in the first place. In fact, one could argue that the entire point of algebraic effect systems is to allow the composition of different effects while respecting an underlying notion of evaluation order.
81 comments
[ 3.5 ms ] story [ 155 ms ] threadGrammar and language semantic are details regarding language adoption.
Unreasonably open-ended question (suppose the scope is ML, or perhaps FP in general, or maybe even wider) - but I'm very curious.
- Proper functional programming support
- Fast enough runtime
- Cross-platform and open-source
- Mainstream ecosystem
- Large corporate backer
- Compile to JS
- Commercial and open-source tooling options
… and yet C# is far more popular. This is because language adoption is driven by existing user base and other network effects NOT the quality of the language itself.
A join is not necessarily a union, since the representations of the three types might be completely different, and also because the third type might contain many values that don't correspond to anything in the two original types. (It might be much bigger than the union.)
Mathematical lattices must also have "meets", which are like joins except down instead of up. I'm not sure that meets are as important as joins in this context.
I thought Scala had an even stricter value restriction than ML, where only function/methods may get a polymorphic type?
CakeML is also a very cool project in SML land.
I've been very interested in CakeML lately, do you use it for anything?
I also disagree that its failure to "succeed" has anything to do with syntax or semantics and solely that it doesn't have a Jane Street or any company publicly behind it.
[0] https://notes.eatonphil.com/standard-ml-in-2020.html
My article was trying to address a much less nebulous issue: what problems did I personally see with SML in the mid 1990s that let me to abandon it at that time.
Any criticism of any language is valid and good to have!
[1] http://cs.uchicago.edu/news/smlnj-overhaul/
This is true, but I think it is also misleading. Haskell has the same problem if you use unsafePerformIO to create a polymorphic IORef at top level. You can then use this IORef to subvert the type system. I think this is something many Haskell programmers are not fully aware of: unsafePerformIO doesn't just break referential transparency; it can also fundamentally break memory safety. Now, you may say that unsafePerformIO is obviously unsafe (it's in the name!) and should never be used. But if you look at many foundational Haskell libraries, you will find that they use unsafePerformIO or similar functions internally, usually for performance reasons. What are the rules that govern safe usage of unsafePerformIO? As far as I can determine, these rules are basically just GHC implementation details, and people often get them wrong. And if you break these rules, you don't just get a function that doesn't do what you expected - you may have subverted memory safety entirely.
I think this is an interesting conundrum. Haskell makes much stronger promises than SML, but if you break the rules, all bets are completely off.
Furthermore, subtle changes in how a function is written (for instance, making a multiplication function not evaluate the right operand if the left operand is zero) can cause wildly unexpected performance changes in that function's callers. In effect, the performance of a function is no longer just determined by that function's structure and by the function calls it contains; performance of one function now depends heavily on the implementation details of others and the context in which that function is used.
Granted, any optimizing compiler can have this effect, but it's rarely noticeable in strictly-evaluated languages, where at least to some extent the order of evaluation must correspond to the structure of the code.
See also: https://en.wikipedia.org/wiki/Lazy_evaluation#Simulating_laz...
If you want conventional lazy evaluation, that is “call by need”, you need to memoize: wrap everything in stateful functions (“thunks” in Haskell-implementer-speak, “promises” in Scheme-speak, but not the same as E/JavaScript-style async promises) that compute the result and save it on the first invocation, but then return it immediately. Apart from efficiency considerations (it’s better to use a tagged union of function pointer and result value instead of calling the function all the time) and a single conceptual wrinkle[1], that’s it.
Call-by-name and call-by-need obviously yield very different complexity, but are in fact equivalent regarding termination, and optimal: if any evaluation strategy terminates on a given term, so do they. The lovely recent paper “Call-by-need is clairvoyant call-by-value”[2] shows that lazy evaluation in fact never does more steps than call-by-need and in fact does a subset of them: those and only those that influence the final result. (The problem, of course, is that none of this addresses memory usage.)
Emulating eager evaluation with lazy can be done if you have seq: (a `seq` b) means “when this is forced, force (the outermost layer of) a, then replace yourself with b (which will then be forced as much as necessary)”: replacing f(e) with (let x = e in x `seq` f(x)) everywhere is not quite enough IIRC, but gets you most of the way there. In a dynamically-typed language, you can implement seq using some of the built-in strictness, like
because null (or any other type discriminator) needs to force the outer layer of its argument (though I’m not sure if you can make this work in bare lambda calculus with functions only, oops); in a statically typed language, a polymorphic seq is AFAIU an unimplementable primitive, but if you need to translate a complete program you should always be able to implement seqT for every type T that occurs in it and use that (and every lazy language has seq anyway).[1] https://srfi.schemers.org/srfi-45/srfi-45.html
[2] https://www.cs.nott.ac.uk/~pszgmh/clairvoyant.pdf, https://youtu.be/S69UOGqda8w
[1] Incidentally, this is why Haskell's garbage collector needs to be able to deal with mutation. A thunk might graduate to an older generation, and once it is finally evaluated it can end up having pointers to the nursery or other younger generations.
Yes, there’s weird perf things that can bite you, but there’s also a bunch of regular bread-and-butter coding things that pervasive makes very much easier.
I once took advantage of this in a light way when designing a compiler targeting C in Haskell, with a goal of making beautiful-ish C code. The language, unlike C, was expression-based, so everything could evaluate to a value. The step that lowered expressions into C syntax returned a struct with multiple fields, each giving a piece of C syntax depending on how the expression was going to be used in context -- was the value of the expression going to be used? or just its side-effect? Then, due to laziness, only one of the fields of the struct would actually be evaluated. (It also handled other cases: lvalues and whether the expression's value was going to be immediately stored somewhere, since then the expression could use that location directly rather than creating a temporary variable if it might have needed one.)
But, the effort to implement call-by-need evaluation (graph rewriting or remembrance of computed value) is higher in strict languages than (sparse) addition of annotations in lazy ones.
The only PL example I can think of is unification, which logic programming has as well as evaluation.
A programming language where you can write programs to rewrite programs.
It appears that author of Pure is prone to come up with PL names that are quite unsearchable.
There is also another GHC extension called bang patterns to request strictness on pattern matches.
The original bang (`!`) which is Standard Haskell can make fields of data structures strict, see: https://wiki.haskell.org/Performance/Data_types#Strict_field...
In general both lazy and strict languages can be strict or lazy. But the way it shows in the data types is different. A strict language can defer evaluation by adding a level of abstraction (instead of type `X` you have `() -> X`). A lazy language can force evaluation (it will have to evaluate at some point anyway) but you might not see it in the type. I’m not sure which one is better to be honest. Being able to infer laziness/strictness from the type is nice, but it also hinders interoperability.
Also, your suggestion will result in identical code being copied for different contexts. Consider lazy structures in OCaml and C#.
[0] http://reddit.com/r/sml
[1] https://www.reddit.com/r/sml/comments/qyy2gs/getting_started...
Sure, it has some warts/differences compared to newer languages - we have moved towards traits/typeclasses/etc, and I wish I could just write #[derive(Debug) - but I feel that SML fits in a very unique spot for programming languages. It's extremely simple, yet still powerful and expressive. I hope we will see continued work on SML/Successor ML descendants (like 1ML, etc), because I think there's still potential there
I think some updated language tooling would dramatically help.
It sounds like a cool project and I'd love to see it! And even have the option to open a PR and help ;)
As for language-server, I'm currently sketching out some plans to use SMLnj's "Visible Compiler" feature, since that seems the easiest path forward. I have a half-baked language-server based on MLton's def-use output, but it's too unstable to share. I am planning to make some progress on the language-server in the next couple weeks.
[1] You'll notice it's just a fragment of the language for now, and only half-implemented. https://github.com/SomewhatML/sml-compiler and https://github.com/SomewhatML/sml-analyzer (again for a fragment of SML)
In the course of my education I experienced some things which I'm convinced are a bad idea even though they worked out OK for me such as selective education (whole schools only for "talented" children) and single sex secondary education, but SML as First Language is not one of those things. It worked well for me and I'm convinced it's a good idea even though I would not advocate writing new real world projects in SML unless you've got some very particular reason.
Despite that there is/was an SML Basis Library project, and a Successor ML project, but it looks like even the die hard fans have drifted away https://smlfamily.github.io/
The issue is precisely an issue of variance, which is mentioned in reference to Scala, but somehow it's glossed over. The type a -> a is covariant in its second argument, but contravariant in its first argument. As a result, you cannot "specialize" it to bool -> bool, because specialization is really another name for covariance.
Another name for contravariance is "generalization". If you ask for something of type "bool -> b", and someone provides you with some function f of type "a -> b", then you're happy. The map f is indeed an instance of "bool -> b": it can eat anything, so it can eat booleans. But with type a -> a, you cannot do what I just did: type "a" is already the most general one, and you cannot do better.
If SML accepted something of type "bool -> bool" for an instance of type "a -> a", then it was a fundamental error. But this doesn't mean that the whole thing should have been thrown out and replaced with monads. In fact, I don't really get how monads have anything to do with the problem at hand. If Haskell can prevent the following code, then I don't see why SML couldn't have prevented the authors' code:
This is all sound if the language is pure, which ML originally was.
There's also a relevant discussion at Stackoverflow. [2]
[1] This is a fairly concise summary of the quantification rules: https://jgbm.github.io/eecs662f17/Notes-on-HM.html
[2] "Does Scala have a value restriction like ML, if not then why?" https://stackoverflow.com/questions/48594769/does-scala-have...
(The naive model of) SML runs into problems because it erases the big lambdas and so evaluates `Λα. ref [α → α] (λx. x)` to a single ref cell of type `∀α. ref (α → α)`
Sidenote: Variance does have some relation here, I couldn't think of a way to trigger bad behavior without a type that uses the type variable invariantly.
But then it will forget that inference when type checking "print ((!m) 23)"; there it will infer that a == int, and that the original definition of m was really "m = ref (fn (x:int) ⇒ x)".
Essentially the issue is that type inference forgets that it already solved for "a" and can't assign it to both "int" and "bool".
In fact, though, α → α is a subtype of bool → bool, for reasons that have nothing to do with covariance and contravariance. We can define a function whose argument is of type bool → bool; here in OCaml:
And we can define a function of type α → α: And that function is perfectly acceptable as the argument to abool, or indeed in any other context where we need a function of type bool → bool: Thus α → α is a subtype of bool → bool, and also int → int, string → string, etc. And this is not because the value restriction has been sneakily applied behind our backs; f and abool still have their original types: And we can still apply abool to a function that's unashamedly bool → bool: So specialization is not another name for covariance, and you absolutely can specialize α → α to bool → bool.Bound variables are variables that either the programmer wrote explicitly in their program or variables introduced by generalization. They appear underneath a quantifier, as in the type
which makes the quantification explicit. However, both SML and Haskell make the placement of quantifiers implicit by default, which is somewhat syntactically convenient, but it obscures this distinction.In HM, a bound variable only unifies with itself, so if we use `~` to mean “unifies with”, then `a ~ a` holds but neither `a ~ Bool` nor `a ~ b` do, assuming `a` and `b` are bound variables. However, bound variables do not actually get involved in typechecking unless the programmer wrote them in their program explicitly, because when a polymorphic binding is used, the typechecker instantiates it, replacing bound variables with metavariables.
There is no way for the programmer to write metavariables in their types, because metavariables are, as their name suggests, a metalanguage concept introduced by the typechecking machinery, not a part of the underlying language of types. This makes writing them down in a way that clearly distinguishes them from bound type variables somewhat difficult, so I will adopt the convention of using Greek letters to represent metavariables. This means we can instantiate the above type to get
which is a very different type! In particular, while bound variables only unify with themselves, metavariables unify with anything so `α ~ α`, `α ~ Bool`, `α ~ a`, and `α ~ β` all hold. However, a metavariable can only unify with something other than itself once, because the process of unification effectively mutates the typechecking context by globally replacing the metavariable with the type it unified with. That is, if typechecking requires the unification `α ~ Bool`, then our function type `α -> α` becomes `Bool -> Bool`, since we perform the unification by globally replacing α with Bool.All of this stuff might seem a bit fiddly, since we’re explaining metavariables in terms of internal details of a typechecking algorithm. But indeed, that’s the point: metavariables are an invention of the typechecking algorithm, a mechanism used to implement type inference. They aren’t really types, they’re “holes” in types that have yet to be filled in by the type inference process. So when you ask a question like “is `Bool -> Bool` a subtype of `α -> α`”, your question is somewhat meaningless, as it depends on what α means for whatever typechecking algorithm you’re discussing, and pure HM does not really have any notion of subtyping (just instantiation and unification).
If we introduce more sophisticated type systems that do have subtyping, then we have the machinery to talk about things like covariance and contravariance. But in HM, no such relation exists, so any notions of subtyping, covariance, and contravariance exist only in our heads, not in the type system itself. Still, we can informally establish a notion of subtyping by saying that if S is a subtype of T, written `S <: T`, then an expression of type S can be used wherever an expression of type T is expected. Given that, we can ask whether
holds. The answer is certainly yes, as we can always instantiate the former to get the latter (which is what you refer to as specialization). But that quantifier is crucial, because it’s what allows us to do the instantiation! If we just have then the relation no lon...To clarify, when I wrote α, I always and only meant a bound variable under a quantifier, because I didn't know about this metavariable thing at all. I was writing α → α because that's how tuareg-font-lock-symbols renders 'a -> 'a, which does include the implicit quantifier.
It's no wonder I was never able to understand HM since I didn't know about this dual nature of variables. This will probably help me a lot next time I try to understand it! I do think I understand unification, though I've only implemented it once.
I really appreciate you being willing to engage on HN, despite the unpleasant aggressiveness and character assassination that runs rampant here.
This sentence is true if you interpret `a -> a` to mean `∀ a. a -> a`, i.e. a universally-quantified type. But it is false if you interpret it to mean `α -> α` where α is an unsolved metavariable, for the reasons I describe in this comment: https://news.ycombinator.com/item?id=31238081
> The issue is precisely an issue of variance, which is mentioned in reference to Scala, but somehow it's glossed over.
Not so! The issue is the incompatibility of mutable references and value polymorphism, which variance alone does not solve. For example, in Scala, you cannot write something like
because if you could do that, then you could write and all hell breaks loose. Note that variance does not in any way save you here—the type variable `A` is always covariant, so this code is variance-correct. Scala prevents this by only permitting polymorphic functions, so you would have to write the above example like this, instead: Now everything is okay, because if you call this function twice, you get two different buffers. This is precisely what the ML value restriction enforces.> Another name for contravariance is "generalization".
Maybe this is true in some sense of the word, though I’ll admit I’ve never seen “generalization” used in this way. But in Hindley-Milner type systems, “generalization” is a term of art that means something fairly specific, namely the implicit introduction of universal quantification, so using it in this context to mean something else may be a little confusing.
> If SML accepted something of type "bool -> bool" for an instance of type "a -> a", then it was a fundamental error. But this doesn't mean that the whole thing should have been thrown out and replaced with monads. In fact, I don't really get how monads have anything to do with the problem at hand.
I sort of agree—introducing monadic structure has essentially nothing fundamental to do with this particular problem. However, it is incidentally true that the monadic encoding of mutable state in Haskell sidesteps the problem. To see why, suppose we translate the Scala example from above into Haskell. Suppose we have a constructor like this:
Note that this is itself a polymorphic value—it isn’t a function! But since it’s wrapped in `IO`, it isn’t itself a polymorphic buffer, just a recipe to create one. If we wanted to trigger the bad behavior, we’d need to be able to create a definition with a type like this: But that isn’t possible to obtain from `newArrayBuffer`, even though Haskell allows polymorphic values. That’s because, in the type of `newArrayBuffer`, the `forall` is outside the `IO` constructor, so in order to actually use it in a computation using `>>=`, we have to instantiate `a` to some concrete type. In other words, `IO` plays precisely the same role here that a nullary function does in Scala: it ensures each instantiation is generative, i.e. it returns a distinct buffer.So Haskell doesn’t need a value restriction because, in a sense, everything impure is subject to a value restriction, with `IO` playing the role of the nullary function type, and that includes anything that contains mutable state. But since Haskell relies on this property of `IO` to preserve safety `unsafePerformIO` can subvert the type system. We can write
Julia uses this in pretty much all of its math functions and probably elsewhere as well, and it works unbelievably well. The type promotion system makes math Just Work, even (and especially) in the face of different-sized numbers. The result is that 99.9% of the time you simply don't have to think about the types of your numbers. Here are some examples from the docs:
And not only are types promoted, but in well-typed Julia code, the deduction of promotion types happens at compile time instead of runtime, so there is almost no performance cost to this either.Silently casting signed integers to unsigned is a terrible idea, and a recipe for bugs. And completely unnecessary to boot, because you could promote Int8 to Int16, and (Int16, UInt16) to (Int32, Int32).
Well....
Signed integer arithmetic and unsigned integer arithmetic do not differ. At all.
The difference between an Int16 and a UInt16 is not in the 16 defined bits. It's in the infinite number of implicit bits representing place values above 2^15. Those bits are always 0 for the UInt16, but they're identical with the high bit of the Int16.
So it's not clear what it would mean for the result type to be "incorrect mathematically". Mathematically, an Int16 and a UInt16 are the same thing.
However, the path by which you promote the values does matter. I don't know what Julia does. But:
is a different result from One of those should be the result Julia gives. I tend to hope it's the first one. That would correspond to a promotion strategy of "always expand the type to its full width before converting between signed and unsigned". Expansion (and shift, I guess) is the only operation for which the difference between signed and unsigned is relevant.> Would a better promotion be Int32 for both?
Probably not; that would imply that when you add two UInt16s together, you expect to get a UInt32 (or UInt17...) back.
Great point on the path dependence.
That depends on what you're hoping to do with the number you're looking for. If you wanted -5 as an Int16, the bit pattern would be 1111 1111 1111 1011 or 0xFFFB.
If you wanted 65531 as a UInt16, the bit pattern for that is 1111 1111 1111 1011 or 0xFFFB. You're getting the same result either way. And any values you compute from that result [that is, by arithmetic] are going to be unaffected by whether you labeled the result "Int16" or "UInt16", because that's just a label. If you labeled 0xFFFB a "Snerf", the arithmetic would still be the same.
There are only a very restricted set of places where you need to be explicit about whether you think of your variable as an Int or a UInt:
1. When you're widening it.
2. When you're doing a right shift.
3. When you're formatting it for display to a human.
It didn't have a big company like Sun pushing it
Here's the way I understand it; be warned that I'm just starting to understand this stuff, so I might have got something wrong. I'd welcome corrections.
S is a subtype of T (S <: T) iff you can use an object of type S wherever you need an object of type T. This gives us a partial order on types, in the sense that
which all follow from the above informal definition. Sometimes we make it a lattice, for example by adding a ⊤ type ("top") that everything is a subtype of and a ⊥ type ("bottom") that is a subtype of everything.In general in the presence of subtyping we can only infer type bounds on most things, not exact types. For example, consider that {3} <: ℤ <: ℝ <: ℂ; if we see a function being applied to the value 3, we can infer that it must be at least applicable to {3}, but a function that is applicable to ℤ or ℝ would also work.
Given this informal definition, surprisingly, α → α (the type of the identity function) is a "subtype" of bool → bool, not vice versa. That is, α → α <: bool → bool. Whenever we want a function of type bool → bool, we can use a function of type α → α, but not vice versa.
α → α is a weird type because it includes an implicit universal quantifier: ∀α: α → α. As it turns out, the judgment above that α → α <: bool → bool is not because α <: bool or bool <: α. It's because bool → bool is what you get when you instantiate the quantifier with a particular value, α = bool.
There's also a function-specific rule for subtyping, and it's a real mindbender: B → C <: A → D if A <: B and C <: D. We say functions' argument types are contravariant and their results are covariant. Considering the A <: B case, if we need a function from integers to booleans, we can use a function from real numbers to booleans—we just won't happen to invoke it with any non-integer real numbers, but it will still work, assuming there aren't incompatible binary representations at play (as in the case of the OCaml +. and + operators MJD mentions.). Considering the C <: D case, we can also use a function from integers to true: it will never return a value we cannot use as a boolean.
We can decompose the operation of taking a reference to x, ref x, into a step of creating a reference r and then applying the reseating operation r := x to it. This operation is valid iff the assignee is of a subtype of the referent type; that is, (r : T ref) := (x : S) is valid iff S <: T. That's because later when we read r we will be using its value as a T, so any subtype of T is fine. So for ref (fn x ⇒ x), we have that S = α → α, so we can derive the type bound that T must be some supertype of α → α. As mentioned above, this includes bool → bool, but it also includes ℤ → ℤ, (ℤ × ℂ) → (ℤ × ℂ), and the polymorphic type α → α itself. So reseating a reference is contravariant: we can write a value we know is an integer or anything more specific (a subtype such as a positive integer) into a reference we know to hold an integer or anything more general (a supertype such as a real number).
The dereferencing operation !r turns out to instead be covariant: !(r: S ref): T is valid iff S <: T. That is, we're going to use...
> Does it print the values in forward or reverse order? One could implement it either way.
but obviously this is also true of `mapM`. That would just be a different function. Monadic sequencing does not help with this at all.
Furthermore, the author mentions algebraic effect systems. It isn’t clear to me from the wording if the intent is to offer them as a solution for the shortcomings of monadic encodings or as a nicer way to pin down evaluation order, but the latter is certainly not true—the two are entirely orthogonal. Algebraic effect systems depend on the evaluation order being well-defined by other means to work in the first place. In fact, one could argue that the entire point of algebraic effect systems is to allow the composition of different effects while respecting an underlying notion of evaluation order.