The Universe of Discourse : Why I never finish my Haskell programs (part 3 of ∞)
blog.plover.com/prog/haskell/what-goes-wrong-...Posted Nov 10, 2018, 8:33 AM UTC
Posted Nov 10, 2018, 8:33 AM UTC
u/mjd has another complaint about how Haskell typing is difficult.
This is actually a harder one than last time -- it boils down to using a parameter at two different types, with the mild complication of the Functor typeclass thrown in. He's working with matrices, represented as lists of lists, and has to convert them back and forth from another functor (to get different list behavior).
He wants to define a function to do both directions:
twice zl m = zl (fmap zl m)
but this uses zl at two different types.
The solution is rank-2 types, which is rather a lot of machinery for something that seems like it should be doable more easily. It's also not exactly trivial to find and describe the problem.
But in terms of actual modifications to use, it's fairly simple:
Add
{-# LANGUAGE RankNTypes #-}
and change
twice :: (Functor f, Functor g) => (f a -> g a) -> f (f a) -> g (g a)
to
twice :: (Functor f, Functor g) => (forall a. f a -> g a) -> f (f b) -> g (g b)
.
How is adding one language pragma and a keyword at the signature a lot of machinery?
As I said, the actual modifications are simple. But RankNTypes are a significant change to the semantics of types, going from Hindley-Milner to full blown System-F, and like many extensions losing full type inference. Compare to some of the other solutions expressed here, with no fancy extensions.
My point is, yes it makes ghc harder to write, but that is something the average user doesn't have to worry about. Loosing type inference just means you have to explicitly annotate a rank n type in the type signarure, but that is a good thing anyway (would you like other programmers to guess where you used Rank-N types?). I am not saying there aren't better solutions for this problem, I just don't think rank N types are a heavyweight feature for what concerns the user.
That's true for full rank-n, but for a rank-2 type (which is what a natural transformation is), type inference is still tractable.
I did not know that. Of course, while GHC does have a language pragma for rank-2 types, it's just a deprecated synonym for rank-n.
To simplify newtype wrapping-unwrapping, perhaps he could make use of coerce:
import Data.Coerce
matrix :: [[a]] -> ZipList (ZipList a)
matrix = coerce
unmatrix :: ZipList (ZipList a) -> [[a]]
unmatrix = coerce
And, using the extra functions from coercible-utils he could write:
matrixplus :: Num a => [[a]] -> [[a]] -> [[a]]
matrixplus = under2 matrix $ \m n -> (liftA2 . liftA2) (+) m n
Which is slightly more elegant because you only have to give the packer, not the packer and the unpacker both.
Also, if he had defined matrix like this:
import Data.Functor.Compose
matrix :: [[a]] -> Compose ZipList ZipList a
matrix = coerce
then he would only have needed a single liftA2 in matrixplus:
matrixplus :: Num a => [[a]] -> [[a]] -> [[a]]
matrixplus = under2 matrix $ \m n -> liftA2 (+) m n
This is really fun and interesting, thanks. But in some ways it's the exact opposite of what I need, because what I need is to get the program done, and seeing this makes me want to put everything else on hold and spend the next week studying `Data.Coerce`.
The other answer involving nested zipWiths is simpler and better, now I see my own answer was overkill.
At the risk of sending you through another rabbit-hole, this talk/paper about a method of representing vectors and matrices in Haskell is interesting.
You can even eta-reduce—and hey, as long as we’re prematurely generalising, let’s make (+) a parameter:
elementwise f = under2 matrix (\m n -> liftA2 f m n)
elementwise f = under2 matrix (liftA2 f)
elementwise = under2 matrix . liftA2
ixplus = elementwise (+)
Oh, all of those are very nice, especially the last.
Still, in context I think the right thing to do is not generalize yet. Two doesn't a big enough pattern make yet.
To solve the posters original problem, I'd like to point out the nifty little function zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]. Let's play with this for a moment. What's the type of zipWith (+)? GHCI says Num c => [c] -> [c] -> [c]. Which looks an awful lot like vector addition to me. Which we can leverage to create matrix addition: zipWith (zipWith (+)) has the type Num c => [[c]] -> [[c]] -> [[c]] and indeed does what we wish it to do. (zipWith (zipWith (+))) [ [1,2], [3,4] ] [ [10,20], [30,40] ] returns [[11,22],[33,44]].
This is a lesson everyone has to learn the hard way. When you're trying to do something simple in Haskell, and the type checker is fighting you, and you find yourself enabling all sorts of crazy extensions, that generally means you missed a left turn in Albuquerque. Learning a new language is hard. Go back, look at other functions, other type classes. You've missed something.
The function ‘twice’ seems like a very obscure generalisation to me... Maybe my bar is just much lower, and I would be happy with a simple zipWith and stop there 🙂.
Yeah zipWith was my first thought as well. It's fast, easy, and gets the job done.
Summary for those who don't want to read 3 blog posts: "because I start generalizing too early instead of doing useful progress".
Yeah, in Haskell it's super easy to parameterize over X, whatever X is. But this should be driven by need, not by mere possibility. So next time you add another dependency to instantiate Contravariant (yes, I know it's in the base 4.12 now) or add another typeclass, or do something similar, think again, why do you do it: is it important for your problem or you just like to feel smart?
The worst that can happen is when you start generalizing a concept of which you only saw a single instance.
I don't think that's what it was this time. Generalizing `matrix` and `unmatrix` to a single one-line function isn't too soon, and it's just the sort of thing that I would do any any other reasonable language without thinking about it.
This time I got bogged down in “isn't it interesting how the type inference algorithm works for `matrix` but not for `twice`. I wonder what is going on there?”
Most languages don't have so much interesting machinery moving around. But Haskell makes me want to stop what I'm doing to study the machinery.
Actually, you started the generalization game even earlier: you didn't need fancy
Functorinstances at all: a simple list-specificzipWithwould do. Instead of using simple types and functions to implement what you need, you assumed thatZipListis the right abstraction to apply without even looking at other operations on Matrices beyond simple addition. If you want to multiply matrices,ZipListonly stays in your way.I implemented a batch of simple matrix manipulation functions once (I was solving an assignment on Coursera involving PageRank computation for a small matrix and I needed
Rationalanswers), it had something likeIt took me maybe 10 minutes to implement all of these + some extra normalization procedures. I'd say it's much more interesting to combine the simple functions you already have to build new stuff than to spend time trying to fit
ZipListinto your model.After you see all these functions together, you might find some nice patterns and try to generalize things (like introducing vector spaces) to reduce the number of function names you have to remember. But generalizing eagerly is almost never a good thing.
There are plenty of times when functions of type
forall a. ...make it clearer. It's well-known as projective programming. The trick is to realize what's actually projective programming and what's just generalization for the sake of generalization.After some time writing Haskell, I have to say I haven't really had many instances where this was unclear. This blog post seems like someone complaining about a relatively trivial, known issue.
YAGNI