Haskell: what are the implications of combining parameters into tuples, rather than using distinct parameters?

Viewed 104

As a Haskell beginner, I'm curious about best practices. In particular, in the absence of other requirements, is it better to associate related function arguments using tuples, or keep them "naked"?

E.g.

vector :: Float -> Float -> Float -> Vector

vs.

vector :: (Float, Float, Float) -> Vector

The reason I ask is that sometimes aspects of a parameter (e.g. x coordinate in a 2D or 3D point or vector) are normally bound up with other parameters (e.g. the y & z coordinates). I can see how pattern-matching can be used in both cases, but I'm curious to know whether there are serious implications "down the track" to using tuples or distinct parameters.

When other parameters are involved, the use of tuples seems to make it clear that a certain set of parameters are associated with each other. But it also makes the code more verbose when functions take just the tuple as a parameter.

2 Answers

I would recommend, as a rule of thumb, to never put tuples in the arguments of a function signature.

Why? Well, if the point is to group stuff together, then tuples do a rather measly job at it. Sure, you could use nested tuples and type synonyms to explain what they mean, but all of that is brittle and much better and safer done with proper record types. As you've identified, the x- and y-components of a vector usually come together. Well, not only that, in many a sense it is a good idea to keep the x- and y-components completely hidden from any interesting code. That's exactly what the Vector type should accomplish. (Which should probably be called Vector3 or ℝ³ instead.) And the only purpose of the vector function should be to assemble one of those from the components. Well, if that's the only thing it does, then the three components are the only arguments, and there's no point grouping them together any further... that's basically just putting a single suitcase into another transport box. Better just use the right container right away as a single wrapper.

vector3 :: Float -> Float -> Float -> Vector3

An example of a tuple in a signature of a commonly used function is

randomR :: (Random a, RandomGen g) => (a,a) -> g -> (a,g)

Why is this a bad idea? Well, you're using a tuple to denote an interval... but also in the result to denote something completely different, a grouping of the obtained random value with the updated generator. The proper way to do this is to either have a type that properly expresses what it is

data Interval a = Interval {lowerBound, upperBound :: a}
randomR :: (Random a, RandomGen g) => Interval a -> g -> (a,g)

...or better, separate the concerns, i.e. that manual state-threading should be hidden in a suitable monad – such as RVar. At that point the range limits become the only arguments, thus you don't need to group them together anymore!

uniform :: Distribution Uniform a => a -> a -> RVar a

That doesn't mean you should never use tuples at all. For result values, the currying mechanism doesn't work as easily†, so if you have a function that gives back two results but there's not really any meaningful interpretation for what those two values represent together, well, give back a tuple.

Furthermore, if you're grouping together completely abstract types, you can't possibly have an interpretation for what they mean together. That's the reason why zip :: [a] -> [b] -> [(a,b)] gives a list of tuples.


†You can also have multi-result functions with tuples. For that, you need to use continuation-passing style, for example splitAt :: Int -> [a] -> ([a],[a]) becomes splitAt' :: Int -> [a] -> ([a] -> [a] -> r) -> r.

There are no implications down the line. A function that can accept one argument first and then another one later, is said to be curried. A function that accepts a tuple as an argument is said to be uncurried. You can convert between the two using curry and uncurry. Feel free to extend this definition to three parameters and define new functions curry3 f a b c= f(a,b,c) and uncurry3 f (a,b,c)= f a b c.

In this case, I would going for a named datatype for most uses. In fact, you already seem to have a Vector type. Making your constructor, vector, accept a triple seems like an excellent idea. That way, those who try to use it to construct a 2D vector will get the most helpful message from the type checker.

Related