One thing I’ve been trying to wrap my head around recently is the idea, popularized in Purely Functional Data Structures, that laziness in languages like Haskell enables algorithmic improvements in the performance of persistent data structures.
The argument goes like this. When you design a data structure, you might not be able to get certain operations to be constant time, but you can get them to be amortized constant time, which means that if you perform them n times, then you only do O(n) work, even if individual operations might cost more than O(1). However, in the context of persistent data structures (data structures which never mutate), this goes out the window. This is because you can find the one operation at one specific state that costs more than O(1), and perform that operation at that state over and over! So amortized constant time requires mutability. However, there is a very sneaky form of mutability that is acceptable “purely”: laziness!
The way that laziness is implemented under the hood is that a lazy value of type a
is actually something like Ref[Either[() => a, a]]
. That is, it is a pointer to either a function that returns a
, or an actual a
. When you attempt to read the lazy value, if it is Left(f)
, then it returns x = f()
and sets its value to Right(x)
. If it is Right(x)
, then it just returns x
. So secretly Haskell is all built on mutation under the hood!
Using laziness, we can fix our amortized persistent data structures issue. Specifically, expensive computations go in lazy values, so that they only get evaluated once. But because laziness is ephemeral to the user, we still get to pretend like we are just using pure data structures.
My thought about this was… are there other types of mutation that could be totally benign? Why is this mutation specifically acceptable? Well, one reason is idempotence. Reading a lazy value twice doesn’t change anything. Another reason is concurrency-obliviousness. If we have multiple readers of a value, it doesn’t matter what order they read it in (except perhaps for performance reasons… also note that while f
is computing you have to somehow “lock” other reads so that they don’t also compute f
…).
A possible generalization of this that has the same properties is semilattice updating. Specifically, suppose that A is a type with a join-semilattice structure, so A is a cocomplete poset. Then if we have a mutable reference r of type A, the operation
is idempotent, and the ordering of the operations r := r \vee x, r := r \vee y doesn’t matter. We call this operation “semilattice updating”.
We can think of a lazy value as having two states: “uncomputed” and “computed”, and uncomputed < computed.
Anyways, my question is: are there “semi-persistent” data structures, whose only form of mutation is semilattice updating? One example that immediately comes to mind is an e-graph. An e-graph stores a partial, congruent, equivalence relation on a set of terms in some formal language. Partial equivalence relations are like equivalence relations but without reflexivity, and an equivalence relation on terms is congruent if f(t_1, \ldots, t_n) \sim f(t_1', \ldots, t_n') whenever t_1 \sim t_1', \ldots, t_1 \sim t_n'. If we have a presentation of an algebraic structure (as given by an algebraic theory) by generators and relations, then we might have an e-graph which only stores some of the derived equations that come from the equations in the theory and the relations in the presentation. Adding more derived equations to the e-graph, however we do it, is an instance of semilattice updating, if we consider the join-semilattice of partial equivalence relations.
E-graphs are data structures which heavily use mutation, but we can see from this that the result is very “benign”.
One might imagine a programming paradigm based totally on semilattice updating, in the same way Haskell is based on laziness, and that would be… propagation networks. I won’t get into the details of that today though.