Three evil ways to avoid conditional compilation

June 15, 2014

GravatarMichael Snoyman

Let's suppose that you're using a library at version 1. It exposes a function:

someFunc :: Int -> String
someFunc i = 'x' : show i

In version 2 of the library, someone realizes that this library isn't general-purpose enough: why is the 'x' character hardcoded? So version 2 exposes a more powerful version of the function:

someFunc :: Int -> Char -> String
someFunc i c = c : show i

In your current codebase, you have:

main = putStrLn $ someFunc 5

Changing that code to work with version 2 is trivial:

main = putStrLn $ someFunc 5 'x'

But what if you want to make your code work with both versions? The real, proper answer, that I hope everyone actually uses, is to use Cabal CPP macros:

#if MIN_VERSION_somelib(2, 0, 0)
main = putStrLn $ someFunc 5 'x'
#else
main = putStrLn $ someFunc 5
#endif

And sure, you should do that... but let's have some fun. I'm going to present three evil techniques to accomplish the same conditional compilation result, and try to point out their relative merits. I encourage others to come up with other ridiculous ideas of their own.

If anyone's curious where I came up with the idea to do this, it was thinking about prerelease GHC patch level releases that break backwards compatibility. And if you want to play around with the code, either open it in FP Haskell Center or clone the Github repo. In all the examples, simply switch whether V1 or V2 is imported to simulated upgrading/downgrading dependencies.

Typeclasses

A well known truth in Haskell is, "for every problem, there's an abuse of typeclasses waiting to be discovered." As far as typeclass abuses go, this one is pretty benign.

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeSynonymInstances #-}
module Typeclass where

import V1
-- import V2

class Evil a where
    evil :: a -> String
instance Evil String where
    evil = id
instance Evil a => Evil (Char -> a) where
    evil f = evil (f 'x')

main :: IO ()
main = putStrLn $ evil $ someFunc 5

What's "nice" about this approach (if anything here is nice) is that everything is compile-time checked, and the code is actually pretty readable. However, as the different cases you want to support get more complicated, you'll need to add in ever harrier language extensions.

Typeable

This approach is for the Pythonista in you. Next time someone proudly states that Haskell is a statically typed language, just pull this one out:

module Typeable where

import Data.Typeable
-- import V1
import V2

evil :: Typeable a => a -> String
evil a
    | Just s <- cast a = s
    | Just f <- cast a = f 'x'
    | otherwise = error "Yay, runtime type errors!"

main :: IO ()
main = putStrLn $ evil $ someFunc 5

Advantage: it's so incredibly trivial to add more cases. Downsides: it's runtime type checking, and the dispatch is performed at runtime, not compile time.

Template Haskell

Of course, no blog post about Haskell abuse would be complete without some usage of Template Haskell. Due to the stage restriction, we have to split this into two files. First, THHelper:

{-# LANGUAGE TemplateHaskell #-}
module THHelper where

import Language.Haskell.TH

evil :: Name -> Q Exp
evil name = do
    VarI _ typ _ _ <- reify name
    case typ of
        AppT (AppT ArrowT (ConT _)) (ConT _) ->
            return $ VarE name
        AppT (AppT ArrowT (ConT _)) (AppT (AppT ArrowT (ConT _)) (ConT _)) ->
            [|flip $(return $ VarE name) 'x'|]
        _ -> error $ "Unknown type: " ++ show typ

Notice how beautiful our pattern matching is. This combines the best (worst) of both worlds from above: we get full compile time checking, and can easily (hah!) pattern match on all possible signatures for the function at hand.

And calling this beast is equally elegant:

{-# LANGUAGE TemplateHaskell #-}
module TH where

import THHelper
import V1
-- import V2

main :: IO ()
main = putStrLn $ $(evil 'someFunc) 5

Comments

comments powered by Disqus

Archives