printf
is the canonical example of a variadic function.
Many people believe that kind of function is impossible
to write in a type-safe fashion, so it’s often brought
into conversation with an obnoxious «Static typing, huh?
I’d like to see how you write printf
in Haskell».
Thanks to Haskell’s type system capabilites, not only is
it possible to write variadic functions, but also a
type-safe, polymorphic, extensible, and composable printf
,
that is way better than your «regular» printf
.
’Tis but a neat trick.
Variadic Functions
Variadic Functions are those that take a variable number of arguments, and even no arguments at all. Note that this is not the same as receiving a possibly empty list of arguments, because that would be a single argument with variable content.
If you’ve worked with C, you’re surely familiar
with a variadic function: printf
. It takes one
or more arguments, and prints them according to the
given format
("Hello world\n");
printf("Answer = %d\n",42);
printf("Answer = %d, Question = %s\n",ans,msg); printf
Many a novice programmer going through the manual
page (man 3 printf
) has been baffled by its
definition
int printf(const char *format,...);
because it’s obvious that the format must be an immutable string, but what’s with those periods?
The C language subroutine calling convention was designed
so that any function can receive at least one
argument, followed by as many additional ones.
Those periods indicate to the compiler not to
worry if calls to printf
have a variable number
of arguments, and to just push them into the stack.
But, that’s it; the compiler will not perform any
type checking on them. Thus, the actual number of
additional arguments, their types, and whether or
not they match their positions within the format string,
are checked by printf
’s implementation during runtime.
If they’re not correct, you’ll get a runtime error.
Languages with slightly better type systems than
C’s also have printf
implemented with more or less
the same restrictions and pitfalls.
Keep calm and curry on
Currying is one of those simple concepts that take you very far, once you learn to look for it everywhere. The first thing that understanding currying does to you, is realizing that all functions in Haskell are actually one argument functions.
The curry-aficionado, when presented with
f :: a -> b -> c
that looks like a two-argument (types a
and b
,
respectively) function, returning a value (type c
),
rather thinks of it as
f :: a -> (b -> c)
a function of a single argument (type a
), returning
a function (from b
to c
).
Since function application is right associative, when we have
f x y
the implicit grouping makes it actually mean
((f x) y)
Currying combined with function application’s right associativity are already useful in the form of function sections and partial applications. Both mechanisms are core to a truly functional language, and we’ll be taking advantage of that in yet another clever way.
Currying and variadic functions
Combining currying and application right associativity we see that a variadic function is something like
f :: a -> (a -> (a -> ... (a -> b) ... ))
where the length of chained applications is
determined by the number of arguments at the
calling place. We should look at f
as receiving
a single argument (type a
), and somehow producing
a function that will capture the next argument at
the calling place, until we’ve captured them all.
At that point, the «last» function, computes the
actual result (type b
).
Let’s start with a simple, concrete, naïve-looking,
variadic function that takes a variable list of
numbers, and adds them up: a variable argument sum
.
Consider the type class declaration
SumArguments a where
typeclass sumArgs :: [Integer] -> a
It specifies that any type a
can be SumArguments
as
long as it’s able to take a list of numbers and
produce a single result value of type a
.
The first part of our trick is making the result
type polymorphic, instead of a monomorphic Integer
.
We are trying to add numbers, but keeping the result
type polymorphic will allow us to decide if it’s
time to add a number to our list, or to actually
perform the addition, depending on the call site
context.
The simplest instance we can write is when
a ~ Integer
. That is, we must implement sumArgs
such that receiving a [Integer]
returns an
Integer
.
instance SumArguments Integer where
= sum sumArgs
It’s that easy. However, it doesn’t seem to help
much, because sum
expects a [Integer]
that we
still don’t know how to build.
Now, consider a call to sumArgs
where there are
many values in sequence, all of them Integer
. What’s
the type of sumArgs
at this particular
call site?
1 2 3... sumArgs
If we look at it as right-associative curried function applications, it’s actually
...(((sumArgs 1) 2) 3)...) (
and now sumArgs
type, in context, seems to be
sumArgs :: Integer -> (Integer -> (Integer -> ...))
Applying sumArgs
to the first argument is akin
to “dropping” the first Integer
from it’s type, so
the result type ends up being
Integer -> (Integer -> ...)
But that is pretty much the same type sumArgs
looked
like before consuming its first argument! That means
consuming the second argument has the same context
as consuming the first argument, so on and so forth
for the remaining ones.
Let’s introduce a new type variable
r ~ Integer -> (Integer -> ...)
and think of sumArgs
as returning a value of type
sumArgs :: Integer -> r
Now, we can write this SumArguments
instance
instance SumArguments r => SumArguments (Integer -> r) where
= \i -> sumArgs (i:is) sumArgs is
There’s a lot going on here. We are saying that
as long as type r
has a way to sum integers, then
there’s a way to add one integer to the tally.
We have sumArgs
receiving a list of integers is
and
returning a function that takes an integer i
,
adds it to current list, and defers the problem of
adding the list to whatever SumArguments
is in
context now.
Closing the loop
We have two SumArguments
instances. Every sumArgs
call site is analyzed by the compiler such that:
If there are arguments remaining to be «collected» the call site has context
Integer -> r
so it compiles to the second instance.If there are no arguments left to be «collected» the call site has context
Integer
so it compiles to the first instance.
The second instance adds to the collection, while the first instance adds-up the collection. The final piece of the puzzle is a helper function – the actual one to use directly.
sumOf :: SumArguments a => a
= sumArgs [] sumOf
Now sumOf
doesn’t look like a function because
there’s only one type in its signature. However,
the SumArguments
restriction allows the compiler
to select the appropriate instance to use. Here
we use calculational reasoning, another tool of
the functional programming trade:
When
sumOf
is called in a context whose type demands an integer immediatelysumOf :: Integer { sumOf definition } sumArgs [] :: Integer { a ~ Integer => use SumArguments Integer } sum [] { Good old sum } 0
When
sumOf
is called in a context whose type demands consuming an integersumOf 1 :: Integer { sumOf definition } sumArgs [] 1 :: Integer { currying and right-associativity } ((sumArgs []) 1) :: Integer { sumArgs context is (Integer -> r) } ((\i -> sumArgs (i:[])) 1) :: Integer { apply lambda } sumArgs [1] :: Integer { see previous bullet } 1
It works out fine.
> sumOf :: Integer
ghci0
> :type sumOf 1 2
ghci1 2 :: (SumArguments (t1 -> t2 -> t3), Num t1, Num t2) => t3
sumOf > sumOf 1 2 :: Integer
ghci3
> sumOf 1 2 3 :: Integer
ghci6
But wait, there’s more!
This trick relies on looking at the type implied
by each call site: if out of arguments, the context
is the Integer
type for the result value, so it sums;
if there’s at least one argument, the context is «need
a function to consume an argument and curry on» (pun
intended). What if the argument were a [Integer]
?
Well, no sweat
instance SumArguments r => SumArguments ([Integer] -> r) where
= \ns -> sumArgs $ ns ++ is sumArgs is
And now we have the incredibly puzzling, yet useful
> sumOf 1 [2,3] 4 [5,6] 7 :: Integer
ghci28
This pretty much sums up the trick. 1
Let’s kick it up a notch
The previous example illustrates the fundamental
trick: look at each curried call site for context,
and give the compiler specific instances to use
when resolving each one. That’s why we need the
result type to be polymorphic in order to be
applicable to any call site. The value collection
remained monomorphic ([Integer]
) because it
wasn’t central to the collecting trick.
But we want to get more flexibility out of this, and the natural next step is to make the collection’s content’s type also polymorphic. That is to say, consider
class Collector a r | r -> a where
collect' :: [a] -> a -> r
Notice how we simply replaced Integer
with a
type variable a
. It becomes a multi-parameter
typeclass, with the ability to collect things
of any type in a list. The r -> a
in the class
signature is called a functional dependency (or
fundep) and it’s needed to keep type inference
working. It reads as «type r
dictates which type
a
is valid in context», and will make sure there’s
only one instance for each type pair. 2
But first, let’s make this new typeclass work.
Collecting polymorphic values
We need to provide two Collector
instances: one
to return the whole collection once we’re done
collecting, and one to add one element to an
existing collection. Therefore
instance Collector a [a] where
= reverse (x:l)
collect' l x
instance Collector a r => Collector a (a -> r) where
= \y -> collect' (x:l) y collect' l x
And we’re going to need the helper function to «seed» the empty collection
collect :: Collector a r => a -> r
= collect' [] x collect x
A few things worth mentioning here:
It follows from
collect
’s type signature, that it requires at least one thing to add to the collection. This mimicsprintf
«at least one argument» behavior.Items are collected by adding to the front of the accumulating list for efficiency reasons. They will be in reverse order once we collect them all but the last. That’s why the list is
reverse
’d just after collecting the last element, to return them in the original order.
The abstraction becomes more useful
> :type collect 1 2 3
ghci1 2 3 :: (Collector a t, Num a) => t
collect > collect 1 2 3 4 5 :: [Int]
ghci1,2,3,4,5]
[> collect True False False :: [Bool]
ghciTrue,False,False]
[> collect 'w' 'a' 't' '?' :: String
ghci"wat?"
What’s the fundep for?
Consider a call site such as
collect 42 :: [Int]
{ collect definition }
collect' [] 42 :: [Int]
Looking at the call site context, the return type
is fixed as [Int]
. Let’s look at the typeclass
signature for collect'
and reason
collect' :: [a] -> a -> [a]
{ take (result) [a] ~ [Int], implying a ~ Int, matches }
collect' :: [Int] -> Int -> [Int]
Great, the call site matches the signature, and we have concrete types for each. Now, which instance shall we use?
We have to match
collect' :: [Int] -> Int -> [Int]
with the first instance
class Collector a r | r -> a where
collect' :: [a] -> a -> r
instance Collector a [a] where
And it matches, because
collect' :: [a] -> a -> r
{ Take a ~ Int / satisfy both arguments }
collect' :: [Int] -> Int -> r
{ take r ~ [Int] / satisfy return type }
collect' :: [Int] -> Int -> [Int]
and we end up with the type at the call site. It means we should use this instance.
But wait! We could also match
collect' :: [Int] -> Int -> [Int]
with the second instance
class Collector a r | r -> a where
collect' :: [a] -> a -> r
instance Collector a r => Collector a (a -> r) where
Because
collect' :: [a] -> a -> r
{ Take a ~ Int / satisfy FIRST argument }
collect' :: [Int] -> Int -> r
{ take r ~ [Int] / satisfy CURRIED type Int -> [Int] }
collect' :: [Int] -> Int -> [Int]
and we end up with the type at the call site. It means we should use this instance, too?
Wait, what? If you’re confused, imagine the compiler.
That’s why we need the fundep!
Having multi-parameter typeclasses with polymorphic arguments and return types may lead to ambiguities like this one. This ambiguity weakens type inference, and we rather have sound type inference.
The functional dependency helps, by narrowing the actual instance based on the unique type at the call site:
If the call site demands
[a]
use the first instance. This helps when the compiler is type-checking the «end» of the variable length argument list, after all arguments but the last have been consumed, and both instances would be applicable.If the call site demands
(a -> r)
, wherer
happens to be a list because that’s our «trick», use the second instance. This applies when the compiler is type-checking the call site at any given argument but the last.
Finally we have everything we need to provide…
A better printf
The previous examples have shown how to build a
type-safe, polymorphic, variadic function, that
takes one or more arguments. We would like to
use the same technique to provide a printf
implementation such that:
It receives at least one argument – «the format».
It receives zero or more additional arguments – «the things to print using the format»
The «shape» of the format helps type-check the things to print. It should establish the type at each position, and it must account for all things to print.
«Be more flexible» – Read in the tone of a dynamic-programming language fan whose notion of flexibility is somewhat inflexible.
A better format specification
«String
is the poor man’s datatype» is something
that I say a lot, with the dismissive tone of a
static-typing language, with an actual type system, fan.
The reason behind your typical printf
shortcomings
has to do with the format being a String
that
needs to be parsed at runtime in order to figure
out what should be in the stack. And that’s why
it sucks.
Let’s improve printf
by having a better format
definition that can be parsed by the compiler, and
helps the compiler check everything is correct.
This new format must:
Preserve order from left to right.
Carry type information, possibly with additional payload, at each position of the collection.
Be efficient – can’t get away with reversing.
Help create a value with a type that forces the compiler to check all values to print are present and have matching types.
Allow the programmer to extend it.
«Be more flexible» – wait and see for yourself.
This hefty list of requirements is easily satisfied if we have one type for each possible format, and each type links to the next item in the format. It will behave as an «heterogenous linked list of position descriptions», built from left to right, each node having a specific type, and pointing to the next one.
Let’s use the following types
data End = End -- End of format
data L f = L String f -- Literal string
data S f = S f -- Like %s
data I f = I f -- Like %d
Each type, except End
, is polymorphic. It allows
values of each type to hold a value of any other,
thus creating a «linked list» where the innermost
one signals the End
of the format. Each type
designates an expected argument, and can carry
additional payload. This design allows for easy
extension to define more formats
data F f = F f -- Format a Float
data B f = B f -- Format a Bool
data G f = G Int Int f -- Mantissa/exponent space
A format value built by nesting these types, is going to
be the mandatory first argument for our printf
implementation.
Plot twist to the old trick
We used the same trick for summing and collecting: have a typeclass that treats arguments as function application, and accumulate them in a list. Of course, they were always the same type.
Since printf
is going to print, this is one of the
few instances where String
is the right type to use.
Instead of accumulating values in a list, this new
typeclass will have a single method that, for each
instanced type, is going to Show
its argument, add
it to a String
, and give the full String
when done. 3
Let’s start with
class Printf f x | f -> x where
printf' :: f -> String -> x
Where f
stands for any format type. Each application
of printf'
will consume one «format» value and the
current accumulator String
. The result type is left
polymorphic, as part of our trick of call site
context instantiation.
Providing an instance for each format type is
straightforward. The End
format type only requires
returning the accumulated String
with everything
already «printed». For the remaining format types,
the instance must build a function that takes a
value from the format-specified matching type,
makes it into a String
, appends to the accumulator,
and proceeds with the next format item. 4
instance Printf End String where
End out = out
printf'
instance Printf fmt x => Printf (L fmt) x where
L s fmt) out = printf' fmt (out ++ s)
printf' (
instance Printf fmt x => Printf (S fmt) (String -> x) where
S fmt) out = \s -> printf' fmt (out ++ s)
printf' (
instance Printf fmt x => Printf (I fmt) (Int -> x) where
I fmt) out = \i -> printf' fmt (out ++ show i)
printf' (
instance Printf fmt x => Printf (B fmt) (Bool -> x) where
B fmt) out = \b -> printf' fmt (out ++ if b then "t" else "f") printf' (
Finally, a few helper functions to make things pretty appealing
= L
lit = S
str = I
int = B bool
This set of helper functions help writing cleaner and clearer formats. Instead of writing
"%s = %d"
we can write
str . lit " = " . int
Note that they are nothing more than function composition. Moreover, every format defined this way is just a function needing an argument (the next format!); this means you can have type-safe reusable formats, i.e.
= lit "the value is "
msg = lit " numeric = " . int
someNumber = lit " a string = " . str
someString = lit " a boolean = " . bool someBool
and then write things like
. someNumber) 42
printf (msg . someString) "forty-two"
printf (msg . someBool) True printf (msg
That is what we functional programmers call «flexibility».
Finally, helper function
printf :: (Printf format x) => (End -> format) -> x
= printf' (f End) "" printf f
is the one actually doing the printing. It applies
the given format to End
, thus completing the format
function application, and uses printf'
on the full
format value, seeding an empty String
accumulator.
Look again at the signature: it will compile only
if you pass a format that’s missing the End
.
This works out as desired
> :type printf ( list "hello world" )
ghciString
> :type printf ( int . lit " is " . str )
ghciInt -> String -> String
> :type printf ( lit "odd( " . int . lit ") = " . bool )
ghciInt -> Bool -> String
> printf ( lit "odd( " . int . lit " ) = " . bool ) 42 (odd 42)
ghci"odd( 42 ) = f"
Using calculational reasoning, it’s easy to see how
each step of printf'
across the format value will
build a function with nested function applications
having their types directed, in order and number, by
the actual format. A simple example
printf (str . lit " = " . int) "ans" 42
{ printf definition }
printf' ((str . lit " = " . int) End) "" "ans" 42
{ desugaring str, lit and int, and applying }
printf' (S (L " = " (I End))) "" "ans" 42
{ substitute Printf instance for S }
(\s -> printf' (L " = " (I End)) ("" ++ s)) "ans" 42
{ apply lambda }
printf' (L " = " (I End)) ("" ++ "ans")) 42
{ substitute Printf instance for L }
printf' (I End) ("" ++ "ans" ++ " = ") 42
{ substitute Printf instance for I }
(\i -> printf' End ("" ++ "ans" ++ " = " ++ show i)) 42
{ apply lambda }
printf' End ("" ++ "ans" ++ " = " ++ show 42)
{ substitute Printf instance for End }
"" ++ "ans" ++ " = " ++ show 42
{ evaluate (++) three times }
"ans = 42"
The compiler will perform a similar calculation, but using
only types. For the arguments ("ans"
and 42
in this example)
it will have concrete types to work on. For the format, it
will have a list of nested types that in can traverse
outermost to innermost. At each step, call site context and
each format element will give enough information to build
the types for the corresponding nested functions, leading to
a fully curried type for the types in the format. That type
must match those of the arguments given.
Best of all, it’s Not At All Magical®
Tell me more about flexibility
There are several points to be made about how this
printf
is way more flexible, while being type-safe, than
the primitive String
-based counterparts provided by
other programming languages.
I’ve already mentioned that formats are actually partially applied functions that can be reused. It doesn’t get more flexible than that.
If a programmer needs a new format for a simple type,
all it takes is a new datatype and its Printf
instance.
Thus, having %c
or the weird %n
are very straightforward,
as well as custom formatting for complex datatypes.
Having format display modifiers, as in how many spaces to
use, left or right alignment, truncation or padding and
whatnot, can be added by having additional Maybe
values
as a payload for each format type and their corresponding
instances using them appropriately.
Ironically, Haskell’s default Text.Printf
implementation
has signature
printf :: PrintfType r => String -> r
The restriction on typeclass PrintfType
allows programmers
to create custom formatting rules for their types, in a
way similar to the one proposed above. However, the
printing format is a (sad) String
5 that gets
parsed at runtime and throws (sadder) exceptions on
mismatches. The attentive reader should not be surprised by
the polymorphic return type r
, because it’s part of the
variadic trick.
If you’ve read so far, you can get the (uncommented) code right here.
In order for this code to compile, you need the
FlexibleInstances
GHC extension. Regular typeclass instances can use type variables only, but we have instances with function types.↩︎In order for this code to compile, you need the
FunctionalDependencies
GHC extension to handle the fundeps. It impliesMultiParamTypeClasses
for obvious reasons.↩︎I kid, of course, because
String
is[Char]
so we’re actually using a list. Semantics…↩︎In order for this code to compile, you need the
UndecidableInstances
GHC extension to allow our instances. The way they are declared violates the «coverage condition» because the variablex
in the right-hand side of the fundep, is not present in the left-hand side. In the general case, this restriction ensures that instance resolution terminates and it’s required by the compiler. In our specific case we know, by construction, that there’s only one instance applicable to each case, something the compiler cannot decide.↩︎To appease the natives, I reckon…↩︎