Function Parameters

Motivation

This post is brought to you by one part frustration, one part discovery, and another part a plea to reconsider how we create functions and APIs going forward.

Modern Functions

A modern function, the type you see in modern high-level programming languages such as JavaScript, Python, Ruby, Go, PHP have their origins in C: they accept input (also known as arguments) and return an output (or maybe not). Below is a simple Go function which accepts 2 integers as input and returns their sum (shrouded in some mandatory Go ceremony):

 1package main
 2
 3import "fmt"
 4
 5func main () {
 6  total := sum(1, 2)
 7  fmt.Printf("1 + 2 = %d", total)
 8}
 9
10func sum(a, b int) int {
11  return a + b
12}

Before we get any further, some definitions.

A function’s arity is its number of parameters. Our sum function above has an arity of 2; written as sum/2 in the name-arity notation. Even though parameters and arguments are used interchangeably, a function is defined with parameters but called with arguments. Referring, again, to our sum function above, a and b are its parameters (see line 10), while the call on line 6 has 1 and 2 as arguments. Parameters are constant, arguments change. Function and method are also used interchangeably. In this post, I differentiate between them as such: a method is a function defined on a receiver. In imperative programming the receiver is usually an instance of a class.

With definitions out of the way, let’s address the biggest challenge of function parameters.

Function Parameters Are Positional

What does this mean?

During a function’s definition its parameters are given in a certain order. That order is a decree to future callers. If and when they use the function they should (1) pass the necessary arguments, but more importantly (2) in the right order. This so-called right order is fixed and non-negotiable. It’s in this stubborn rigidity that the problem lies. I’ll illustrate.

Take for example, exp, a nifty Python function for exponentiation. It calculates the e-th exponent of a base. exp is defined as follows:

def exp(e, b):
  """
  Returns the e-th exponent of b.
  To find the 5th exponent of 2, exp
  is called as such: exp(5, 2).
  """
  return b**e

Does the order of the parameters make sense to you? If your definition of exponentiation is a base b raised a power e you’d intuit that a function that does exactly that will be defined as such:

def exp(b, e):
  """
  Returns the e-th exponent of b. That is,
  b raised to the power e. To calculate 2
  raised to the power 5, exp is called as such:
  exp(2, 5)
  """
  return b**e

We agree on the implementation of the function. We agree that the base and exponent should be parameters so that we can use it on a variety of inputs, but we disagree on the order of the parameters, and if your version isn’t chosen then you’d have to live with the dissonance, constantly referring to the source code or documentation to learn the order of arguments. We’ve made a parameter’s position important and subjective to the programmer. In my opinion, this is not good.

We’re lucky here though. exp has an arity of two so there’s a 50% chance we’ll guess the order right. An arity of 3 reduces it to 17%. One more parameter and you can’t rely on your intuition anymore. See for yourself:

You can put your intuition to test here. Below are input fields which collect arguments for a function that prepares a sweet bio for your CV based on your first name, last name, Twitter, and GitHub handles. Can you guess which input field corresponds to what argument?

Make Me a Bio

Did you give it a try? Could you figure out the order of the parameters? God is with you if you did. Otherwise this is what probably happened: You thought the first input was first name, closely followed by last name. You were probably uncertain of the order of the Twitter and GitHub handles, but you found a (false) clue in the description of the function and thought it was Twitter first, followed by GitHub. But reality didn’t match expectation and so after a couple of tries you probably gave up. You subconsciously acknowledged that some form of documentation was necessary. “How is anyone expected to use the function without it?” you ask.

Did you give up? Did you peek under the hood for help? Did you find it there? Did you feel miserable? If you did, you’re not alone. Anybody who has tried to use a date/time function feels the same. And they usually have an arity of 6 and above. Can you imagine?

To date, figuring out the order of a function’s parameters is the number one reason I look at its documentation. In my opinion, referring to documentation or source code for no other reason than to learn the order of a function’s arguments is unacceptable. It’s a fixable problem, and we should actively work to fix it.

The Fix

We have seen that after three parameters our ability to intuit deteriorates beyond repair. Anything lower than three and our intuition is good enough. For example, we can guess with 100% accuracy the order of arguments to a zero- and single-parameter functions and we don’t have to consult any documentations. At two, our accuracy drops to 50%. Trial and error makes sense here: if one ordering doesn’t work the other will. Again, we avoid another trip to the documentation or source code in search of the right order.

How do we put this discovery to use? Can we still make powerful and useful functions if we set maximum arity to two?

I’m glad you asked. The answer is yes. I argue in favor of zero, one, and two arity functions below.

Zero-Parameter Functions (fn/0)

In imperative (or mutative) programming, zero-parameter function can be achieved with instance methods. Below is a class that represents a document. It has two methods, encrypt and publish which take zero arguments but are able to achieve their goals because they have access to the internal state of the instance:

class Document
  # NOTE: We will fix this definition of initialize in the
  # next section when we make rules for one-argument functions.
  # All hope is not lost.
  def initialize(title, content, author, publisher, published, free)
    @title     = title
    @content   = content
    @author    = author
    @publisher = publisher
    @published = published
    @free      = free
  end

  # Returns an encrypted version of the document.
  # Please note: This is not how to encrypt.
  def encrypt
    @content.reverse
  end

  # Makes the document available on the interwebz.
  def publish
    @published = true
  end
end

Zero-parameter functions are meaningless in functional programming.

Single-Parameter Functions (fn/1)

Consider Elm, the delightful language for reliable web apps. As at the time of writing, all functions in Elm are defined with one and only one parameter. This is a non-negotiable and intentional constraint. Haskell too. These languages are able to achieve this because they rely heavily on (lightweight) types. They’re typeful languages. Composite types! Types, with their named fields, erase the necessity of positions.

They’re a pleasure to document, use in conversations. Even more to use them. Take for example a hypothetical send_email/1 function which sends, you guessed it, emails.

For a hypothetical send_email/1 function, all we need for documentation as far as parameters are concerned is: send_email/1 takes an email (with email hyperlinked to its documentation). Otherwise things get kinda messy: send_email/8 takes the following parameters in this order: from, to, reply_to, cc, bcc, subject, text_body, html_body. Depending on who should be cc-ed or bcc’ed and whether or not you want both text and HTML body, you’re left with sawtoothed calls such as below.

send_email("from@mail.com", "to@mail.com", "", [], [], "Subject", "Text Body", "")

As you can see, exploding the email type and passing it’s fields as individual arguments to send_email/8 introduces unnecessary overhead. It doesn’t make for great conversation either.

Now, I said lightweight types because I want to exclude classes like we get in Ruby, Python, Java, etc. They are not lightweight, either to create or to chug along. An empty Ruby class is already bogged down by about 60 methods and attributes. That heavy, it doesn’t make sense to create them to be used as function arguments. Python’s namedtuple comes close to a lightweight type. I’ve used Elixir and Go’s struct with a lot of delight. They are lightweight composite types that are fit for the purpose of single-parameter functions. We need something more lightweight in Ruby.

In the absence of lightweight, named data structures to pass to our functions, we should turn to classes and methods. They can do a good job. For example, send_email/8 easily becomes send/0 on an Email class. With chain-able implementations of from, to, subject, etc., this beauty is a possibility:

email = Email.new
email.
  from("a@b.c").
  to("d@e.f").
  bcc(["g@h.i"]).
  subject("hey").
  text_body("text").
  html_body("html").
  send()

Empty initialization, attributes set with chain-able methods. Come to think of it, this gives us better API stability: when we don’t initialize new instances with arguments but set and unset them via methods we can easily add new attributes to the class without breaking existing code. A maxim is in order: Initialize empty, build later.

But maps, I hear you say. Yes, hashmaps or objects or dictionaries or associative arrays cut it. With them we don’t have to worry about order anymore. I’ll take them over sawtoothed calls. I wish maps could be typed though.

I consider variadic functions as single-parameter too. For example, Go rewrites variadic arguments to a single slice argument. More importantly, order doesn’t matter, which is what we’re ultimately striving for.

Types and classes get as far. Little, tiny, specific, special-purpose classes can make it possible to not exceed a single-parameter. Use them liberally to achieve this goal. Pass a data structure to the function. It’s most likely all you need. Add another method to the class.

Two-Parameter Functions (fn/2)

Very rare cases. Very special functions. Functions for comparisons, swaps, diffs, every function that needs two and exactly two things, of the same type usually. More than two and we can replace with a list instead and a single-parameter function.

Another special group of functions in this category is what I call applicators. These are functions that apply other functions to their arguments. Think of map, reduce, filter of an iterable data type. Their imperative cousins can still remain single-parameter.

Another group is registrar functions. They usually register callbacks. The first argument is a known, predictable event. The second argument is usually a function to call or notify. Very popular in Pub/Sub systems such as event handling (see DOM’s JS API).

These special-purpose functions enable extensibility. I think applicators are a brilliant idea. If your function takes 2 arguments could it be for the reason of extensibility? Shouldn’t the second argument be a callable?

Of course there’s always that one function that lies outside all patterns. You’re unlucky if you have to write them. All I can do is wish you well. I hope it doesn’t become your norm.

OK I’ll end here. This rant is already longer than it should have been. I’ve been unhappy at my work lately and found it a good way to vent about my frustrations. But I like how it turned out. I hope that in a follow up post I’m able to articulate a few rules I personally follow for making delightful functions. And I hope you’ll find them useful.

Closing Thoughts

I’ll leave you with this closing statement. It’s often said that code is written to be read by humans. The sequel to that is, and functions are created to be used by humans.

Next time you build an API keep this advice in mind. Be considerate of, first, your future self, and then your users. Intuition is a good thing; reinforce them! Above all, create and use types, and keep function parameters to a maximum of two. As I tried to show, it’s very possible. Colleagues, strangers on the internet who find solace in your work, your future self, and more importantly, your ongoing self will be thankful.

Got comments or corrections for factual errors? There’s a Hacker News thread for that.