Implementing Pure Functions Functional programming enthusiasts have long understood the benefits of pure functions — excellent encapsulation, low cognitive load, threadsafe, and so on.



A while back, I wrote an article explaining how the D programming language's "pure" annotation could be applied to functions. A pure function does what you'd expect — the compiler enforces purity of the function. The function may not have any side effects, may not rely on any mutable data, and is statically checkable.

Functional programming enthusiasts have long understood the benefits of pure functions — excellent encapsulation, low cognitive load, threadsafe, and so on. Pure functions have gained popularity among the D programming community as well. Good D style encourages the use of pure functions whenever practical.

But there's a problem.

Pure functions can only call other pure functions. One might say "it's turtles all the way down." Now, suppose we've got a rather deep call chain of pure functions calling pure functions, and we suspect a bug lurks in the deepest, darkest one of them sitting in bottom of the sump pump in the basement.

What's the first tool many of us grab? Stick a print statement in it to verify that the function is actually being called, and what it's arguments are, such as:

import std.stdio; pure int square(int x) { writefln("square(%d)", x); return x * x; }

Uh-oh. The compiler means it when it checks for function purity:

test.d(5): Error: pure function 'square' cannot call impure function 'writefln'

Let's examine the options:

Remove the pure annotation. That will enable square() to successfully compile, but then every pure function that calls square() will now fail to compile. The "turtles all the way down" now becomes "turtles all the way up" as you are forced to remove the pure attribute from the entire function call graph. Clearly, this is very unappealing. D has an all-purpose escape from type checking — the cast. Casting is a blunt instrument used to get us out of all kinds of jams:

import std.stdio; int square_debug(int x) { writefln("square(%d)", x); return x * x; } pure alias int function(int) fp_t; pure int square(int x) { auto fp = cast(fp_t)&square_debug; return (*fp)(x); }

The pure function is split into two, and the impure one is bashed into submission by forcibly casting it to be pure. The best face we can put on this is that it works and gets the job done.

But I hate it.

D is meant to be a joy to work with, and there's no joy in this. It's time to think of a language modification.

In a functional programming language, using monadic output for debugging messages may be the right choice. But D does not use monadic I/O, preferring straight calls to I/O functions. Leaving the debate of which is better to philosophers, let me just assert that monads should not be necessary for taking care of this small matter in D.

Programmers just want to stick the print statement in and have it work despite it being impure. How can we accommodate that, yet still be pure? Is there anything in D that can be pressed into service for this?

Yes. D has something called a debug statement. Debug statements are regular statements prefixed with the keyword debug. They are compiled only when the -debug switch is passed to the compiler. With a simple change to the language, we can disable purity checking inside a debug statement. So, to make our original example compile:

import std.stdio; pure int square(int x) { debug writefln("square(%d)", x); return x * x; }

Simple, easy, and it works. The compiler otherwise still treats the function as pure, so depending on the situation, the user may see a variable number of writes. This is because the compiler is allowed to cache the result of pure functions, or on the contrary to call them repeatedly.

Some uneasiness about this is understandable. After all, it is breaking purity. The rationale is that debug code is not production code. It's reasonable to be able to break the rules as necessary for debugging. Of course, the onus is on the programmer to not introduce a heisenbug (where the program works when compiled with -debug and fails without).

Thanks to Andrei Alexandrescu, Brad Roberts, David Held, and Bartosz Milewski for their helpful comments and corrections on this post.