The Unison Programming Language
unisonweb.org
unisonweb.org
and it will make so, so much more sense.
...and if you are like me you'll probably need to read this twitter thread to get the answer to your #1 question: https://twitter.com/unisonweb/status/1173942974381744134
Basically the core idea (or one of the core ideas) is instead of a function (like fib(n) which returns nth Fibonacci number) being identified by its name (fib) as is the case with most traditional languages, it's instead identified by a hash of its implementation.
By having the linker work on hashes of implementations you eliminate that problem but create a new problem. You can no longer change the behavior of the function because you can't change the function. That means you can't suddenly change behavior that some caller is counting on, but it also means you can't fix bugs without changes in the caller.
Maybe the simplest solution is to allow the function to change to the new version, but make it easy to revert in the event that something breaks. This of course means that you can't make the names of the functions their hash (without lying, preventing the runtime from checking that hashes ways match, or modifying emitted bytecode or native code to do what you want), it has to be an orthogonal layer on top of them like types (as I mentioned elsewhere in the thread).
when you change a function implementation the system has to walk the callers graph backwards starting from all the places where the function was called updating all the implementations with the new hash, then callers of these with the new implementation and so on up to main (or whatever it's called).
I had a chance to implement something like this in a system that used jbpm 3 graph language (basically process X version 1 called process Y version 1 and I updated process Y to version 2). It's nontrivial especially with recursion, I'm wondering how they are dealing with that.
f: Nat -> Nat
g: Nat -> Nat
h: Nat -> Nat
h x = g (x * 2)
g x = f (x * 3)
f x = x < 0 ? 1 : h (x / 4)
And now you change f to be f x = x < 1 ? 1 : h (x / 4)
How do you do that? There's a cycle in the callgraph. In fact - how do you calculate a hash of a function that calls itself if you need its hash to calculate its hash :)EDIT: nevermind, recursion is a special case handled differently.
But then when it comes to ideas like this we just tend to say "we're trading one set of problems or another", as if we can't evaluate the problems in a similar manner. And I'm not picking on you here, I tend to do the same!
Yes, we're trading one set of problems for another, but what if the old set of problems was "O(n²)" and the new set of problems is "O(nlog(n))"? Or maybe it's the other way around. Why isn't it obviour how to apply those earlier skills here?
(0) It is easy to order two real numbers. 1 is greater than 0. But can we order points on the cartesian plane (1,2) or (2,1) or (0,100000000000)? Already at 2nd dimension not all points are easily ordered. There is solution "just give priority to 1st coordinate", but can you really completely disregard memory usage and focus solely on cpu?
(1) Theoretically, to get "best", one needs to evaluate not only O(n) of avg cpu usage, and avg memory usage, but also worst and best cases, while using knowledge of input data distribution (e.g. maybe input is almost sorted). Memory access patterns, cache locality, battery life, suitability for your hardware also must come into play. (many dimensions)
(2) Practially one has to do profiling on real hardware with real configurations / inputs / workloads. Different workloads may favor different algorithms/structures. (again results with many dimensions)
(3) Due to constrained time people will not even go over all algorithms. Real people will immediatly rule out really bad ones, then pick 1 or 2 algorithms that theoretically are good enough match, and see if their profiling results fit in cpu/memory budgets. (even if budget is not on paper, but just part of intuition)
Sound a lot like darklang.
Like others, I am dubious about this being in any way a useful feature. Separating implementation from name (/interface) and binding to that interface/name instead of the implementation is one of the fundamental and useful parts of abstraction.
One challenge I foresee is unintentional coupling. Say you have two functions:
func serialize(MyRecord) ...
func debugToString(MyRecord) ...
Now if you ever make the mistake of having giving those the same implemention, then in Unison they'd be the same hash reference, right?
Then if you want to update, say the debug print later it would update all callsites for that hash including the ones that originally called serialize(). The two are no longer distinguishable.
It's similar to how DNS can have two domains point to the same IP, but then you can change one of those domains point to a new IP.
But how do you know which name was called where if the callers referenced the content hash not the name?
If I understand Unison right, the names are used only on the developer's layer(to write code), but when you save code, it's all hash-based.
Still, Unison got my attention.
This is definitely an issue that is real, and is currently a problem, and that we will fix; probably by giving the function author an option to salt the hash of new definitions that have some semantic meaning beyond their implementations (appropriate for most application/business logic). No salt for definitions whose meanings are defined by their implementations (appropriate for most generic "library" functions like `List.map`).
We already make this distinction for data types, but not yet for value/function definitions.
Not only do they have to provide a salt themselves but on top of that, they need to make a judgment call of when something has "more semantic meaning beyond their implementation" (to use your words) rather than being some more "fundamental" code.
I'm also surprised that you haven't solved this problem yet: at least once a day, IDEA warns me that some portion of my code is duplicated exactly in some other area of my code, so this kind of duplicated logic is already quite common.
With a traditional programming language you couldn't do this because "send me a copy of sort()" would be met with "which sort()?". Whereas with unison every different sorting implementation would have different hash, so there would be no confusion.
- Storing the AST on the disk in a million files is not necessarily the best use of the filesystem. In contrast, most languages store text files on the disk, and build up a similar AST in memory only
- You can't view your code without special tools, which means all text editors/version control etc. need to be Unison-aware
- Since the language is append only, all edits look like additions in version control
- Their solution for the diamond problem (depending on multiple versions of the same library) is having hard dependencies on exact versions, and including both copies can be at best wasteful, at worst bad (what if v2 fixes a bug that was in the v1 dependency), I think this is a hard problem, and the reason why semver exists
- As others have mentioned, the append-only nature of the language makes bugfixes difficult
- Solutions that dynamically discover code dependencies and automatically run tests exist for both procedural and functional languages
- Detecting that 2 things are the same through hashing is nontrivial, can it detect that 1 + x + 1 is the same as x + 2? The ASTs are different
(Which is not to defend the rest of the append-only immutability of the rest of the language, that looks a bit whack -- but then I've seen whack stuff get wildly popular, so I have no idea -- but while having 2 versions loaded at the same time might be useful I'm not sure I want to deploy every version that has ever existed that smells way too bloated)
You can scream at the developers that they've violated semver but a "bugfix" is entirely subjective (relevant xkcd, spacebar heating, etc).
And even when developers violate semver in a point release the problem still exists. They actually rarely, if ever, rollback with a 1.0.2 that is equivalent to 1.0.0 and instead usually move forwards.
And if you have a language that supports loading 1.0 and 1.1 then there's no point in being artificially constrained over which two versions can be loaded at the same time based on the label, the underlying framework shouldn't be built to care. There's no need for a multi-version library loader to care about what a bugfix is.
Semver would just be an artificial impediment at this level.
It doesn't contradict SemVer.
However when you do that, it becomes a big deal to jump from one version to another even if the breaks are minor. So pros and cons.
So say there is v1 and v2 of a utility lib in my dep tree, but actually only using func A from v1 and func B from v2. Then I just have the AST of v1.A and v2.B in my deps and everything works.
A new codebase format just uses a sqlite database instead of a million files
> Since the language is append only, all edits look like additions in version control
Traditional methods of showing change in verson control, that is text diffs, don't make sense here anyway
> Detecting that 2 things are the same through hashing is nontrivial, can it detect that 1 + x + 1 is the same as x + 2? The ASTs are differen
It can't detect that. It if could it would be pretty cool, but I don't think it would improve the usability too much
Hashing was created to prevent collisions and ensure small changes have big differences in result. The first requirement makes sense here, but not sure how the second helps.
Unison solves the issue - there isn't any binary incompatibility, because the transitive versions of Bv1 and Bv2 cannot be in conflict - the function references are to guaranteed unique and different versions of the art.
As for bug fixes - you can specify in your code exactly which version to use.
As for editors needing to be unison aware - they just delegate everything to the compiler via lsp and bsp.
Bug fixes are no more difficult than making the change. A new version is created, and your code can now depend on it. Old code will still run off of the old version. It's up to the code owner to decide to use the new, but fixed version.
Version control is all handled in the language itself.
As for the hard hashing problem... Runar is a particularly intelligent individual. I expect that his algorithm works pretty well.
The first argument about storing the ast is moot in an age where cached compiled typescript, Python, and .class files take up inordinate amounts of disk space.
> Solutions that dynamically discover code dependencies and automatically run tests exist for both procedural and functional languages
Eh. Piping and yarn ain't got nothing on maven and ivy and apt. But yes, dependency management isn't anything new under the sun. Dynamically resolving individual function versions in packages alongside binary incompatible functions is.
I'm not saying it is worse than nothing, but sometimes ideas have a way of sticking around too long and making people comfortable.
The hard part is coming up with the normalization routine which guarantees that (lambda (a) b a) == (lambda (b) a b) and coming up with the rules for statement reordering for top level and internal definitions so that you can identify semantically equivalent statements where the outcome is order invariant. This is critical for making the hash functions useful and I suspect preventing denial of service attacks on the human brains that have to audit the code.
Being able to write a version of the code and then do the equivalent of creating a package.lock file to crystallize the hashes seems like a reasonable workflow. This probably winds up being easier in common lisp though since you can put the crystallized implementations in their own packages.
You could also view this as a kind of extreme type theory where every function (with regular names) has the type of its normalized representation (compacted to a hash for sanity's sake) and then you can run the checker to see if the types/hashes have changed. If you have somewhere that keeps track of every hash that a function with a particular name has had then you could automatically refactor, or could even support having multiple versions of the function with the same name used in a program at the same time. I'm not sure how users would feel about having to carry around `(funcall ((with-norm-id '(lambda (+ a b)) f)) a b)` though ... probably just give up on editing the textual representation and go back to the image based approach of Smalltalk and Interlisp where you can hide the hashes.
Will be interesting to see how Unison evolves.
The first thing people do is check in textual representations of those things in version control and operate on that instead.
Unison: A Content-Addressable Programming Language - https://news.ycombinator.com/item?id=22156370 - Jan 2020 (12 comments)
The Unison language - https://news.ycombinator.com/item?id=22009912 - Jan 2020 (141 comments)
Unison – A statically-typed purely functional language - https://news.ycombinator.com/item?id=20807997 - Aug 2019 (25 comments)
Unison Language March Update - https://news.ycombinator.com/item?id=19528189 - March 2019 (1 comment)
Large-scale, well-typed edits in Unison, and reimagining version control - https://news.ycombinator.com/item?id=9708405 - June 2015 (11 comments)
Unison: a next-generation programming platform - https://news.ycombinator.com/item?id=9512955 - May 2015 (128 comments)
“The Mess We're In” by Joe Armstrong at Strange Loop [video] - https://news.ycombinator.com/item?id=8342755 - Sep 2014 (77 comments)
> Code is stored as a structured, type-checked tree in a database, not as text in files
What does everyone think a filesystem is?
The file system is in an entirely different and irrelevant layer of abstraction.
I'm sure this analogy is technically incorrect but: This reminds me of Smalltalk and old Lisps on mainframes shared by many researchers where the main thing was the VM image, not an object file. Though the probably kept the source code around? At a gut level getting rid of source code makes me uncomfortable but I'm ready to learn more.
PS sorry for the ugly raw links I'm on my phone
I think you may be misunderstanding what is being stored here. Now as a caveat I'm not familiar with this language, but I am familiar with the concept as described. They are not removing source code, rather source code is stored after some processing; in this case it appears to be after lexing, parsing, and type checking. I'm not sure exactly what is being stored, i.e. an AST, but it sounds like they're basically moving this stage of compilation/interpretation to be much earlier in the process.
I'm assuming this database can be queried and the result can be rendered back to a textual presentation as well. Presumably this opens the door for syntax being divorced from language semantics since how the syntax is parsed into the database and how the database is rendered into text can be a client side decision rather than set in stone inside the compiler/interpreter. What is set in stone is the semantics of the database that everyone must agree to.
Again, there's the caveat that I'm not familiar with how this language in particular is implementing this concept.
The article gives an example that most programmers would be familiar with; canonicalization so that version control and code reviews go smoothly. Version control also becomes somewhat simpler as it can compare the structure of code rather than the structure of a sequence of characters that still must be lexed, parsed, etc. There are lots of other areas where storing code in a structured database of some sort would benefit tooling as well. One example is the use of language servers to index, perform continuous recompilation, perform cross-reference lookups, and offer code completion. With a structured database a lot of this becomes relatively trivial.
I'll definitely have to look into this language further as I'm curious about how their database is designed.
Programming with a codebase manager and a scratchpad is just so much fun - I found myself hypnotized and came back an hour later with some janky min heap code. Definitely seems to scratch an itch for me.
Very cool core concept. Reminds me of some things Rich Hickey has said about the idea of versioning dependencies at the function level
That said: I wonder if this idea would make more sense as static analysis on an existing language. It would have to be trivial to enumerate all code that might influence a function's behavior; so something totally pure like Haskell or Elm
I'd be very interested in learning about analagous static analysis tools for referentially transparent languages / purely functional languages with sufficiently expressive type systems. Please share what you find :)
The actual code lives in a sqlite file in the .unison/v2 folder. That would mean existing tools like version control and editors would need to learn about how Unison works in order to seamlessly support it. Also pulling out code into a scratch file, editing it and pushing it back into Unison's database sounds kind of annoying. Again, this could probably be solved with an editor that would make this process more seamless and feel more like editing regular code.
As it currently stands it seems very cumbersome to use, mostly due to the tedious process of even just exploring a codebase, nevermind modifying it.
See also https://share.unison-lang.org/ where you can look at the base library, and some (contributed?) libraries as well.
Edit: also worth mentioning that thanks to specialized editors you don't need to manually browse through files but you can browse your code similarly to https://share.unison-lang.org if you so please. That's another plus point of the vast existing ecosystem, it already offers so much and it's a shame that Unison can't make use of it (at least for the moment).
The gist of it is that you can check out sections of code that you want to work on as a plain text file and you can do whatever you’d do with a text file: open it in your editor, syntax highlighting, copy/paste, whatever floats your boat. The cool part is that the “Unison codebase manager” (ucm) watches the scratch files and re-parses them whenever a file changes. I presume any syntax or type errors will be immediately shown in the ucm output. Cool, you say, but we can already do that with file watchers like `entr` and traditional languages, so why should I care? Well, it goes further.
You can start a line with a > character followed by an expression and the expression will be evaluated when you save the file, printing the output inside of ucm. It’s basically a REPL that you control from your editor. Cooler still, building on this concept is the `test>` prefix which, you guessed it, creates a unit test and runs it inside ucm, showing you whether it passed or not. And as a consequence of Unison’s content-addressable nature, after a test has run for a given expression’s content hash, the result is cached and the test doesn’t need to be re-run unless the hash changes (impure functions are soooo 2020). After you’re done with the scratch file, you can run `add` in ucm to add either certain parts (I think) or all of the work you’ve done in the scratch file to the source codebase, and this includes the tests that you wrote along with their cached values (I think)!
I personally find this workflow to be very compelling. To me, this approach is much akin to the source control that we do today, but it's actually aware of the context and meaning of the changes. Git, on the other hand, relies on weak heuristics to figure out what changed between versions of text-based files.
I am very happy to see projects that push beyond the boundaries of the paradigms we’ve been stuck in for the past 60+ years. I also find it quite funny that Hacker News, a forum centered around startups, can often be so conservative when it comes to new technologies.
[0]: https://www.unisonweb.org/docs/tour
[1]: https://www.unisonweb.org/docs/tour#unisons-interactive-scra...
But then, what is the point of this content addressed code again? What do we gain from it that we don't already have now? With current file based version control you already have an append only repository, code is never deleted from the .git directory, it's just not always mapped to a file in the source code directory (until you check out an old revision, that is).
Edit: I guess Unison still has the unique feature that dependencies are referred to by identity and not name.
That's not to say this isn't a limitation the project will need to overcome to be useful, just a caveat.
[1]: https://corecursive.com/027-abstraction-and-learning-with-ru...
One thing I didn't see in my (admittedly quick) perusal of the tutorial and faq: what is the technique to run a Unison program from the command line? Is it practical for making unix cli tools (yet)?
One thing I didn’t see skimming the language reference page: is there any sort of typeclass mechanism?
My guess is yes, since that would probably forbid modifying the instance, since we would now have both the new and old copy. Unless of course we couple type class instances with the definition of the type itself (or the definition of the class) and view the type together with all its instances as a unit (as Unison does with mutually recursive functions), but that would bring on its own set of issues.
How is recursion handled? To get a hash for a function you have to have hashes for every function it calls. Is there special "recurse" opcode?
And how do you update a function implementation when you have a cycle in the callgraph?
Simply put, there is a special recurse opcode.
When you have a cycle in the call graph, they all get hashed together as a single unit; you update them together as a single unit too.
https://news.ycombinator.com/item?id=27492727
The implications of this, with the right frameworks and processes, seem potentially huge.
But the thing is that APL quickly becomes a "write only" language - as far as I know, the main use of APL someone sitting at a brockerage who can cobble together any algorithm on twenty minutes and often throws away the result afterwards.
Which is to say, Unison is interesting because it seems to underestimate the importance of a program's code as document, as complete, coherent, human-readable, single-view, intentionally created text. Why hasn't the stream of ascii been replaced as the format of program in the last twenty years? It's a good question but the answer isn't that it's just matter of conservatism. There are several other things involved.
Ugh, this conflicts with my favorite file sync tool: https://www.cis.upenn.edu/~bcpierce/unison/
And then ‘ucm -codebase /somewhere/else’ to launch ucm.
Also I’m not sure what Unison the file sync tool stashes in .unison or how it uses that directory but there might not be any conflict. UCM will just create a .unison/v2/unison.sqlite3 file.
Question for the devs: How does one deploy Unison code? After my first glance through the docs I don't have a clear picture.
Doesn't that mean that the git repository will only ever grow, and that old code will stick around forever? I hope I'm misunderstanding because that would be unfortunate if true.
In practice, Git's content-addressable storage and delta compression make it work fairly well for all but the largest repositories.
For instance, assuming C definitions, an integer and a file descriptor have the same content but probably should not be treated as the same type (I wouldn’t want arithmetic to type check against file descriptors…).
Another scenario: say I have a type “Foo” which contains an integer. In version 1 of my library, this integer must be even, but in version 2 I add support for odd integers, too. The Foo data type, from a content perspective, is unchanged. However, the invariants around it have changed and it’s therefore essential that it becomes a new type. Otherwise, someone might create a Foo containing an odd integer using the version 2 API and then pass it to a function from the version 1 API, resulting in bad things since the version 1 API believes Foo can never contain an odd integer.
The same question came up for terms, here: https://news.ycombinator.com/item?id=27654045
In that case, I guess data types can have a 0-size marker member, kind of like Rust's `PhantomData` type, that could ensure distinctness.
The one thing I ran into (as someone who only vaguely knows haskell) is that it seems like it's impossible to write a function that takes a list of A or B as an argument and then branch on the type of each element. I can use Either but then I need to decorate each element in the list with Left/Right rather than just use their types.
This is probably just not how things work in Haskell and I just need to be okay with that.
Yep, that's just how things work in Haskell: disjoint unions are much simpler regular unions, and they're usually what you want in the first place. I think it'd be nice if Haskell had automatic conversions between types (so a and b can be turned into Either a b implicitly, with an error if a = b) but I don't think there are any plans for that.
[0]https://www.schoolofhaskell.com/user/Gabriel439/sum-types [1]http://deliberate-software.com/christmas-f-number-polymorphi...
It looks like a programming language from the present at best. A programming language from the future would have finally broken free from the prison of plain text.
Unison is a language in which programs are not text. That
is, the source of truth for a program is not its textual
representation as source code, but its structured
representation as an abstract syntax tree.
This document describes Unison in terms of its default
(and currently, only) textual rendering into source code.
Or to put it more concisely, Unison is currently a plain-text programming language.It is more accurate to say, Unison is an AST language which nowadays happens to have just one human-readable interface, which is text.