Menu

What Would it Be Like to Do Functional Programming in Go?

By Noam Yadgar
#functional #haskell #go #programming #software-engineering

Disclaimer: Go is not a pure functional programming language. This article does not try to teach you functional programming in Go since imposing functional programming concepts, may result in unnecessarily awkward code. Go has its style, and it’s proven to be highly successful. Therefore, there’s no point in forcing a foreign dialect on Go code. Haskell would be a great choice if you want to express yourself functionally.

This article aims to provide imperative programmers a bridge to the functional world. Crossing that bridge and seeing the other side can help us reimagine problem-solving and add new skills to our set. We will encounter Go translations to some original Haskell code, hoping this will allow us to understand the original dialect.

Speaking about translation, if you’ve asked me to compare programming languages to spoken languages, I’d say that Go is a dialect of C. Haskell, however, is not a dialect of C. It’s much easier to translate one dialect to another when the two dialects are of the same language; it’s more difficult when they’re not. Translation can either lose information or bend the cultural perception of the original content. I will do my best to provide a balance between the two.

Defining the Pair type

A prevalent thing about programming languages is the use of types. In both Haskell and Go, we can define data types. Let’s define a Pair type, a datatype with a pair of values of some other generic type.

Haskell:

data Pair a = Pair a a

Go:

const ()
  X = iota
  Y
) // using X and Y as indices for clarity later on

type Pair[a any] [2]a

func NewPair[a any](v1, v2 a) Pair[a] {
  return Pair[a]{v1, v2}
}

You might be surprised, but the two examples above are similar in practical terms. The Go code provides more technical specifications because I’m specifying that the two values are also paired together in memory. Notice that in Go, we provide a “constructor” function that returns a Pair. In Haskell, we specify that we can “construct” a Pair of genric type a, by simply referring to Pair a a (we’ll see how this is done later).

Making it a Functor

If your datatype seems to act as a “container” of another generic type, it’s an excellent candidate to be a functor. In this article, we won’t go deep into the definition of a functor, but we’ll simply implement the behavior of a functor on a set of Pair.

In Category Theory, this will be called an endofunctor, a functor that maps a category (in our case, the category of types) to itself.

Haskell:

instance Functor Pair where
  fmap f (Pair x y) = Pair (f x) (f y)
  -- fmap can also be called with its "infix" operator: <$>
  -- fmap f (Pair x y) = f <$> Pair x y

Go:

func Fmap[a, b any](f func(a) b, p Pair[a]) Pair[b] {
  return NewPair(f(p[X]), f(p[Y]))
}

The two examples are similar in practical terms. The practical meaning of a functor, is that we can take any function of type (a -> b), apply it to a Pair of a, and get a Pair of b. For example:

isEven := func(x int) bool { return x%2 == 0 }
Fmap(isEven, NewPair(1, 2)) // {false, true}

Or in Haskell:

even <$> Pair 1 2 -- Pair False True

As we will see next, in Haskell, there’s a concept of proving to the compiler that a datatype is a curtain instance of some class, by gradually proving (implementing) its subclasses. So we just proved that a Pair is a Functor.

Making it an Applicative

If our Pair is a Functor, it might also be an Applicative. Applicative is a very efficient class in Haskell; it takes the idea of a Functor one step further. Instead of providing a function of type (a -> b), we will now provide a Pair of functions of type (a -> b). Essentially, chaining pairs of values without ever leaving the functorial context of a Pair. It also provides a way to “lift” a value into a functorial context. In our case, we will lift a value into a Pair context, by using the pure function.

Applicative is very close to be a Monad, which we will not cover in this article.

Haskell:

instance Applicative Pair where
  pure x = Pair x x
  Pair f g <*> Pair x y = Pair (f x) (g y)

Go:

func Pure[a any](x a) Pair[a] {
  return NewPair(x, x)
}

func Apply[a, b any](p Pair[func(a) b], q Pair[a]) Pair[b] {
  f := p[X]
  g := p[Y] // declaring f and g for clarity
  return NewPair(f(q[X]), g(q[Y]))
}

Making (Pair Num) a Num

Here, we observe the true power of functional programming, particularly Haskell expressions. Defining a Pair as an instance of an Applicative is paying off, as we define a Pair of Num as a Num. In the following definition, we’re no longer talking about a Pair of generic type, but specifically, a Pair of any type that satisfies the Num class (any number). Our goal is to prove that a Pair Num is itself a Num.

Haskell:

instance Num a => Num (Pair a) where
  p + q         = (+) <$> p <*> q
  p * q         = (*) <$> p <*> q
  negate p      = negate <$> p
  abs p         = abs <$> p
  signum p      = signum <$> p
  fromInteger n = pure $ fromInteger n

Now for the Go translation (take your time to digest):

type Num interface {
  int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
}

func Add[a Num](p, q Pair[a]) Pair[a] {
  opPlus := func(x a) func(a) a {
    return func(y a) a {
      return x + y
    }
  }
  return Apply(Fmap(opPlus, p), q)
}

func Mul[a Num](p, q Pair[a]) Pair[a] {
  opMul := func(x a) func(a) a {
    return func(y a) a {
      return x * y
    }
  }
  return Apply(Fmap(opMul, p), q)
}

// in haskell it's automatically defined at the class level (p + negate q)
func Sub[a Num](p, q Pair[a]) Pair[a] {
  return Add(p, Negate(q))
}

func Negate[a Num](p Pair[a]) Pair[a] {
  return Fmap(func(x a) a { return x * -1 }, p)
}

func Abs[a Num](p Pair[a]) Pair[a] {
  return Fmap(math.Abs, p)
}

func Signum[a Num](p Pair[a]) Pair[a] {
  opSig := func(x a) a {
    if x > 0 { return 1 }
    if x < 0 { return -1 }
    return 0
  }
  return Fmap(opSig, p)
}

func FromInteger[a Num](n int) Pair[a] {
  return Pure(a(n))
}

As you can see, these function compositions are a bit awkward when written in imperative languages like Go. Composition is critical to functional programming, and functional languages excel at expressing function composition. For the imperative programmer, the Haskell code seems to be using magical-syntactic sugar to achieve such a compact definition of the Num (Pair Num). However, this definition truly is a complete one.

Let’s focus on the first function: addition (I’ll leave you the rest as an exercise):

(+) <$> p <*> q

Intuitively and symbolically, I like to think of this expression as if we’re “stitching” two Pair Num together, and our needle is the (+) operator. Going beyond the symbolic meaning of this expression, we can break the expression into 2 parts and analyze them separately:

(+) <$> p -- or: fmap (+) p

We’re mapping a Pair Num to the (+) operator, which in Haskell, is just a regular function. (+) is a binary operator (a function that takes two arguments) and in Haskell, we can write it in an “infix” form like 1 + 1, or in a “prefix” form like (+) 1 1. But there’s more to it. In Haskell, all functions are unary in disguise (they take only one argument). Haskell utilizes an extremely important concept (from Lambda calculus) called currying, which allows us to call (+) with a single argument and return a function of (+) x that waits for the second argument to be complete.

Since:

f <$> Pair x y = Pair (f x) (f y)

Therefore:

(+) <$> Pair x1 y1 = Pair ((+) x1) ((+) y1) -- a Pair of two functions of type Num -> Num

Now, we can replace the first part of the expression so that:

Pair ((+) x1) ((+) y1) <*> q

The Applicative operator of Pair is:

Pair f g <*> Pair x y = Pair (f x) (g y)

Therefore, the entire expression is evaluated as:

Pair ((+) x1 x2) ((+) y1 y2)
-- => Pair (x1 + x2) (y1 + y2)

No magic here, just function composition. We can now interact with Pair Num as if it were a number itself, using simple arithmetic operations.

Pair 1 2 + Pair 3 4 * Pair 5 6 - Pair 7 8 -- Pair 9 18

Or in Go:

Sub(Add(NewPair(1, 2), Mul(NewPair(3, 4), NewPair(5, 6))), NewPair(7, 8)) // {9, 18}

Conclusion

As you can see, even though Go is able to express all of the functional ideas presented in this article, we had to compromise readability and structure to achieve this. This comparison, however, is not fair. It’s completely biased towards Haskell.

If this article aimed to show implementations of bit-manipulating algorithms, then Go would have been the better choice. Lower-level languages can directly talk about the machine’s infrastructure, while functional programming languages are generally focused on virtual structures. We can put this idea on a scale. One side is a language with a one-to-one model of the machine at the expense of readability and structure when modeling virtual constructs. The other side is a language that lets us easily reason and model virtual constructs at the expense of direct machine control.

machine_vs_declarative

Where do we go from here?

I should come clean. Even though I advocate for functional programming, I do most of my programming in Go. Go is proof that simplicity, being highly portable, having exceptional package management concept, built-in tools, and a good ecosystem are integral parts of what makes a programming language useful. When you need to get things done, Go will get you there quickly.

However, I also believe that the future of programming is marching towards declarative-functional languages. Like elevators, which have moved on from having a human operator, or manual gear cars, which have declined (not to mention the rise of self-driving cars), declarative-functional languages further abstract system-level programming, allowing us to focus more on the “what” and less on the “how”.

Of course, this movement toward abstraction is not new. Javascript programmers trust the underlying C++ engine to make optimized decisions, and engineers working on a Javascript engine also trust the C++ compiler to make optimized decisions about the Assembly code. The only difference here is that functional programming has more formalized, restrictive rules about declaratively, as it has its roots in Lambda Calculus, a Turing-complete system based on pure statements and no state. Lambda Calculus is not as physically feasible compared to a Turing Machine (i.e Von Neumann architecture), but if built on top of it, a robust system of logic and computation.