4 ms·
A sibling comment correctly lays out what the type means, and I'll elaborate a little more here. > how the data is stored Thinking of how concretely the data
by dwohnitmok 5y ago
A sibling comment correctly lays out what the type means, and I'll elaborate a little more here.
> how the data is stored
Thinking of how concretely the data is stored is a little bit tricky. I find it easier to build an intuition by looking at what kind of values the type signature lets us build, and which values are disallowed.
runLengthOfAnEmptyList : RunLength []
runLengthOfAnEmptyList = Empty
-- Note that 1 is shorthand for (S Z)
runLengthOfAListWithOneElement : RunLength [ 1 ]
runLengthOfAListWithOneElement = Run 0 1 Empty
-- The definition of RunLength places restrictions on how to
-- construct it.
-- For example, this will not compile.
runLengthOfAListWithOneElementBrokenVersion : RunLength [ 1 ]
runLengthOfAListWithOneElementBrokenVersion = Run 1 1 Empty
--
-- This violates the type signature of RunLength
-- Notice Run 1 1 Empty which has changed the 0 to a 1
--
-- As a reminder this is the definition of Run
-- Run : ( n : Nat ) -> ( x : ty ) -> ( rle : RunLength more ) ->
-- RunLength ( rep ( S n ) x ++ more )
--
-- n is set to 1, x is set to 1, and Empty means that more is set to []
-- Making the necessary substitutions results in:
-- RunLength ( rep ( S n ) x ++ more ) -- No substitutions yet
-- RunLength ( rep ( S 1 ) 1 ++ [] ) -- S 1 is the same thing as S (S Z)
-- RunLength ( 1 :: 1 :: [] ++ [] ) -- Substituting rep ( S 1 ) 1
-- RunLength ( 1 :: 1 :: [] ) -- ++ [] doesn't change our list at all
-- RunLength [ 1, 1 ] -- Syntax sugar for 1 :: 1 :: []
--
-- But this is a contradiction! Since we said in our type annotation that we
-- should have RunLength [ 1 ], not RunLength [ 1, 1 ]!
However I agree with you that this style of dependently typed programming is confusing and ultimately in my view, a bad representation of how dependent types should be used in production code (if any full-spectrum dependently typed language gains enough adoption to be used in large production projects).
Here's the same idea in an alternative, but (roughly) equivalent, encoding, which hopefully lays it out more clearly.
RuntimeEncodedList : Type -> Type
RuntimeEncodedList t = List (Nat, t)
uncompress : RuntimeEncodedList t -> List t
uncompress runtimeEncodedList = case runtimeEncodedList of
[] => []
(n, elem) :: restOfList => rep (n + 1) elem ++ uncompress restOfList
compress : List t -> RuntimeEncodedList t
compress = ...
uncompressIsTheInverseOfCompress :
uncompress (compress xs) = xs
uncompressCorrectlyUncompressesEmptyLists = ...
I hope this is a much plainer and easier to understand representation, with a similarly strong invariant. The `...` are of course eliding a lot, but my point is that they can be elided, without really affecting comprehension of the overall code (which is not the case with the other representation).
This also nicely avoids a problem I see in a lot of dependently code where the structural representation of data structures are intertwined with the invariants they are supposed to uphold. Not only does this result in increasingly confusing code as the invariants get more sophisticated and also awkwardly privileges certain invariants over others, it makes modifying the representation in even the smallest ways a nightmarish test of resolve. This is because everywhere you want to create the data structure, you must also prove that the invariants hold. Sometimes this proof is trivial, sometimes this proof is difficult. But you must always do this, you cannot bail out with some sort of "believe me" escape hatch where you maybe use some sort of external solver to prove it instead, because you can now potentially crash or even segfault your program, even if your code would otherwise fulfill the invariant through some other reasoning the compiler cannot see, since the proof is an integral part of the runtime representation of the data structure.
This sort of proof burden means that it can be a Herculean task to swap out the underlying representation of the data structure for a more efficient one (e.g. you're going to be in for a hard time if you've written code with the `RunLength` type and all of a sudden, for speed reasons, you'd prefer an underlying representation of run length encoded types that groups all the run lengths in one array and stores all the actual values in another array). At least having zero multiplicity annotations on parts of the dependently typed data structure makes things a bit easier.
I instead advocate for a clean separation where the overwhelming majority of data types in a dependently-typed program should be non-dependently-typed and dependent types used instead merely to state properties about those data types independently.
- throwaway17_17 5y agoThe line of thought you are expressing here really reminds me of the work on Zombie, a research language that Stephanie Weirich, among many others, worked on. In particular was the dissertation research by Chris Casinghino. He gave a talk at Boston Haskell [1] that has some really well argued points that argue for your perspective in many respects. 1 — https://m.youtube.com/watch?v=_2jrmgO_Gq0 https://m.youtube.com/watch?v=_2jrmgO_Gq0
- dwohnitmok 5y agoOoooo... even on a brief skim through this looks like a great talk (although I guess that's an easier thing to say when the speaker mainly agrees with your own viewpoint). Will watch in more detail. Thanks!
- atennapel 5y agoI haven't written enough dependently typed programs to know what's best, but just an argument in favour of the OP version :): I agree that your version is easier to understand. But with the other version the types at least guide you in the implementation of the compress/uncompress. In your version you can return any `List t`.
- dwohnitmok 5y agoFor the sake of discussion I'm going to say something stronger than I believe, but which I don't think gets enough airtime so I want to expose its most extreme version. Having sufficient type direction such that there is only one possible implementation of a function is a bad thing. It represents a tightly constrained data type that will be painful to make even the smallest changes to down the road and duplicates code for no real benefit. The definition of `RuntimeLength` does not do away with the need to define `uncompress`, all it does is move it into the definition of `RuntimeLength`. In fact my version of `RuntimeEncodedList` and `uncompress` is less overall code than `RuntimeLength` and `uncompress`. Indeed the only reason the compiler can synthesize `uncompress` is because it duplicates `RuntimeLength`! So really the compiler hasn't reduced the amount of work that needs to be done. Just like I could've messed up my definition of `uncompress`, its equivalent in `RuntimeLength` could also have been written incorrectly. We haven't reduced the amount of code that needs to be checked by a human for correctness (in the absence of other invariants), only moved it from a function definition to a data type definition. By moving it into a function, however, we can then selectively add as many additional properties as we want (such as the `uncompress(compress xs) == xs` invariant), without having to worry about privileging one over any other.