Intro

Back in my university days (mid to late 2000’s) one of the most taboo conversations you could start with your classmates and professors alike was writing code sans if/else. In particular, our Software Engineering professor made it clear that he would downgrade our assignments if the code contained if or switch statements, or even the ternary operator. His rationale was that true OOP was based purely on objects passing messages to each other, and that this ought to be enough to implement any behavior. He gave an example using Smalltalk, showing us that the language doesn’t even have the if/else constructs.

At one point, a classmate from a different cohort posted this (and only this) to our internal Google group:

http://www.antiifcampaign.com/

Note the http protocol in the link (which now redirects to this site), revealing the time when this was posted (Oct 2009). Moreover, the fact that he decided to just put the link there and refrain from making any statements should tell you that he knew that this was the equivalent of lighting a match in a room full of gunpowder. He was right. Chapeau 🎩.

What

If you go through the site in question you’ll see that it tries to sell you some workshop, explaining that using if statements makes your code worse. This is not the content of the original site that sparked our debate back in 2009, but this Wayback Machine reference gets us closer. Its original purpose was to be controversial and make people argue like crazy over this. Fun times…

The author does make a good point about the downsides of what he called the Let’s Put an IF syndrome. His thesis basically states that code that evolves with the business by making use of if statements to implement new functionality ends up becoming a maintenance nightmare. I can relate to this, especially in areas where the code handles extremely critical logic (e.g. billing). Take the following code from Stripe’s Ruby client as an example:

Disclaimer

I do not expect you to go through it thoroughly. Also, I love Stripe from a developer’s point of view, their DX is one of the best I’ve experienced in my entire career.

Notice the case statement on line 130 (equivalent of a C/C++ switch statement, a sneaky form of chained/nested if/else statements). This block is making decisions based on the data type of a variable. There’s also an inlined if statement on line 142 that conditionally assigns a value to obj.filters. I’m sure that if you spend a few minutes reading this code, you’ll understand what it does, but that’s not really the point. The author of this code probably had an easy time convincing their peers that this method was simple enough to be understood and merged. Moreover, given that a lot of the code in the library follows a similar structure, they had an additional argument in favour of their work: sticking to the current style. But as someone reading the code later (which happens much more often than writing it), you usually keep a mental model of all the relevant parts that are involved in a use case, so as simple as one of those parts looks when writing it, its complexity is multiplied by a factor $k > 1$ when reading it.

How

So if we decided to join the Anti-IF Campaign, how would we go about it? What alternatives do we have in order to write complex logic in a “pure” way? I’ll provide 2 popular examples that IF-defenders use when presenting arguments, namely:

  1. Taking certain actions based on the state of an object (e.g. a file)
  2. Performing some “computational” algorithm (e.g. a sorting algorithm)

I’ll work those examples in languages (Smalltalk and Lisp, respectively) that are barely used and have no if/else defined as part of their syntax since these are the cleanest approaches that I can put together given my knowledge. I’ll also make up an example of my own in Ruby. The reason behind choosing Ruby is that the language provides this extremely powerful (and dangerous!) feature named metaprogramming that allows us to redefine/customize the language itself (not quite, but it gives us that illusion).

If you’re unfamiliar with any of these languages, you can just Google them. They’re pretty well known at this point, and there are OSS alternatives for all of them.

Smalltalk

This is completely optional, but if you wish to try these examples yourself you’re more than welcome to. I’d even encourage it, trust is for posers. For Smalltalk, I used GNU’s implementation, which you can install using brew, apt-get or any other major package manager.

The first example I’ll cover refutes the idea that you need to use if/else statements to implement what is commonly known as the State pattern. From the Design Patterns book:

The State pattern is used in computer programming to encapsulate varying behavior for the same object, based on its internal state. This can be a cleaner way for an object to change its behavior at runtime without resorting to conditional statements and thus improve maintainability.

Let’s imagine a case where we have to write some text to some files, like server logs for example. Most operating systems require files to be “opened” prior to writing content on them. After a file is successfully opened, it becomes open (yeah…). If it’s not open, then it’s closed (yeah…). Operations on closed files are forbidden.

In very simple terms, we’ll define a file manipulation class that keeps track of the file’s content, and also provides some useful methods to open and close the file as well as read and write the file’s contents. Because of what I mentioned above about how some operations depend on the state of the file, the class must keep track of that state so that these methods do the right thing. In order to do that, the class must be responsible for:

  1. Storing the state somewhere, ideally in an internal variable that cannot be arbitrarily manipulated by other objects
  2. Managing state changes/transitions accordingly
  3. Checking the state before performing any operation that depends on it, so that the class abides by the operating system’s rules

Leaving the underlying filesystem aside, I’ve written an extremely minimal definition of such a class (that I called MyFile), together with a small script at the bottom that actually uses it. To comply with the first responsibility, MyFile declares 2 internal variables named content and state. The former can be ignored, as its purpose is to emulate the storage supporting these read/write file operations. The latter variable is the one that’s actually relevant because that’s were the class keeps track of the file’s state. Please also note the init instance method, which is responsible of assigning the initial values to these variables; in particular, set the initial state of the file to “virgin” (as in, untouched):

 1Object subclass: MyFile [
 2    | content |
 3    | state |
 4
 5    init [
 6        content := OrderedCollection new.
 7        state := #virgin.
 8    ]
 9
10    MyFile class >> new [
11        ^super new init.
12    ]
13]

To manage the state changes in OOP, classes rely on encapsulation which is a fancy way of saying “I don’t want your dirty hands touching my variables, just let me know what you want me to do I’ll do my best”. It’s like having a secretary that suffers from OCD and won’t let you touch anything on their desk, you can only call them on the phone and ask them for something like setting up a meeting with a client. Classes in OOP emulate this pattern by defining methods that other classes or objects can invoke to get things done. Smalltalk goes a step further and refer to these as messages to make it clear that you’re actually communicating your intentions to an object. Alan Kay, the creator of Smalltalk, and one of the fathers of OOP, once said that he regretted using the term “object-oriented programming” because it was misleading. He thought that the term should have been “message-oriented programming” instead, because the focus should be on the messages that are passed between objects, rather than the objects themselves.

For simplicity’s sake, let’s say that a file can be in 3 different (and mutually exclusive) states:

  1. #virgin: untouched, nothing has even been done yet other than pure initialization of the object. Once an object transitions out of this state, it can never go back to it.
  2. #open: the class has opened the corresponding file successfully at some point. Only “virgin” objects can become open.
  3. #closed: the class has closed the file already. Only open objects can be closed.

To perform these state transitions, MyFile offers 2 methods/messages called open and close:

 1MyFile extend [
 2    open [
 3        state = #virgin
 4            ifTrue: [ state := #open ]
 5            ifFalse: [
 6                state = #closed
 7                    ifTrue: [ ^self error: 'File cannot be reopened' ]
 8                    ifFalse: [
 9                        state = #open
10                            ifTrue: [ ^self error: 'File is already open' ]
11                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
12                    ]
13            ]
14    ]
15
16    close [
17        state = #open
18            ifTrue: [ state := #closed ]
19            ifFalse: [
20                state = #closed
21                    ifTrue: [ ^self error: 'File was already closed' ]
22                    ifFalse:
23                        state = #virgin
24                            ifTrue: [ ^self error: 'File was not opened yet' ]
25                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
26            ]
27    ]
28
29    state [ ^state ]
30]

I also included the state method just so that we can visualize something for now. If you’d like to try this code yourself, you can copy and paste it into a file and write some code to exercise it, like this:

"Create a new instance of MyFile, and print its initial state"
f := MyFile new.
f state printNl.

"Open the file, and print its state"
f open.
f state printNl.

"Close the file, and print its state"
f close.
f state printNl.

The above script will produce the following output:

~$ gst with_if.st
#virgin
#open
#closed

Experienced (or traumatized) devs will be able to tell that things will start getting really ugly really quickly. As soon as you have transitions between all possible states, you need to check if those transitions are valid for any combination of states, which grows at a rate of $\mathcal{O}(N^2)$ (more precisely, ${N \choose 2}$). For our example, we can put together a table representing the state transitions:

Initial StateEnd StateValid?Method
#virgin#virginNo
#virgin#openYesopen
#virgin#closedNo
#open#virginNo
#open#openNo
#open#closedYesclose
#closed#virginNo
#closed#openNo
#closed#closedNo

open and close are the methods that are responsible for manipulating the state of the file, but that’s not actually useful for the user of the class. The end goal is to provide a way to read and write the file’s content, ensuring that the operations are valid or letting the user know otherwise. To do that, we need to add at least the read and write methods to MyFile so that the user can read or write the file’s content. Below is the full implementation of the class:

 1Object subclass: MyFile [
 2    | content |
 3    | state |
 4
 5    init [
 6        content := OrderedCollection new.
 7        state := #virgin.
 8    ]
 9
10    MyFile class >> new [
11        ^super new init.
12    ]
13
14    write: newContent [
15        state = #open
16            ifTrue: [ content add: newContent ]
17            ifFalse: [
18                state = #closed
19                    ifTrue: [ ^self error: 'Cannot write to a closed file' ]
20                    ifFalse: [
21                        state = #virgin
22                            ifTrue: [ ^self error: 'File was not opened yet' ]
23                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
24                    ]
25            ]
26    ]
27
28    read [
29        state = #open
30            ifTrue: [ ^content join: (Character lf asString) ]
31            ifFalse: [
32                state = #closed
33                    ifTrue: [ ^self error: 'Cannot read from a closed file' ]
34                    ifFalse: [
35                        state = #virgin
36                            ifTrue: [ ^self error: 'File was not opened yet' ]
37                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
38                    ]
39            ]
40    ]
41
42    print [ self read printNl ]
43
44    open [
45        state = #virgin
46            ifTrue: [ state := #open ]
47            ifFalse: [
48                state = #closed
49                    ifTrue: [ ^self error: 'File cannot be reopened' ]
50                    ifFalse: [
51                        state = #open
52                            ifTrue: [ ^self error: 'File is already open' ]
53                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
54                    ]
55            ]
56    ]
57
58    close [
59        state = #open
60            ifTrue: [ state := #closed ]
61            ifFalse: [
62                state = #closed
63                    ifTrue: [ ^self error: 'File was already closed' ]
64                    ifFalse:
65                        state = #virgin
66                            ifTrue: [ ^self error: 'File was not opened yet' ]
67                            ifFalse: [ ^self error: 'File is in an inconsistent state' ]
68            ]
69    ]
70]
71
72"Our file object"
73f := MyFile new.
74
75"We write some text to it, but not before opening it!"
76f open.
77f write: '# Title'.
78f write: 'This is a test file'.
79
80"We read and print the contents of the file"
81f print.
82
83"We close the file, then try reading from it and it all goes to hell 😈"
84f close.
85f print.

What would happen in practice if the number of states grew? We’d not only see the code get even uglier, but we could also be opening the door to invalid states which is even worse.

In contrast, we can apply the State pattern to this problem, maintaining (almost) the exact same interface, and at the same time making our teammates (and ourselves) happier in the future by making it easier to read and maintain. The idea is to create a class for each state, and delegate the responsibility of managing the state behaviour and transitions to these classes. The MyFile class will be responsible for defining the interface (the same one we had in the previous example), and we can subclass it according to our state transition table:

  • VirginFile: a class representing a file in the virgin state
  • OpenFile: a class representing a file in the open state
  • ClosedFile: a class representing a file in the closed state

Pretty intuitive, right? However, for this to be possible and “clean” we need to introduce a couple of additional complexities (welcome to engineering, the art of tradeoffs).

The first complexity comes from the fact that MyFile mainly represents our interface, and it must be agnostic to these state classes. This is also known as the Liskov Substitution Principle (LSP). Not only is it an accepted best practice (thus contributing to the Principle of Least Surprise), but it will also allow us to create/remove states without having to modify the MyFile class. So a natural problem that arises from this is: how do we instantiate the correct state class, if we’re only allowed to “know” about the MyFile class? The answer is to use the Factory pattern, which roughly speaking involves having a class responsible for knowing which class to instantiate given the circumstances (and how to do it). In our case, we can create a class called MyFileFactory that will be responsible for instantiating the correct state class. For our example, this factory will always create an instance of VirginFile, but in a real-world scenario it could be more complex than that:

1Object subclass: MyFileFactory [
2    MyFileFactory class >> newInstance [
3        ^VirginFile new
4    ]
5]

The second complexity comes from how we call certain methods that change the state of the file. In the previous example, calling open or close would mutate the state within the same object. In the case of a State pattern, because we’ll be instantiating a different class, we need to return a new instance of the class that represents the new state. So the signatures of the open and close methods will no longer return “void”, but rather an instance of (a subclass of) MyFile. So we’d need to make a change like this to the test script from before:

@@ -1,11 +1,11 @@
 "Create a new instance of MyFile, and print its initial state"
-f := MyFile new.
-f state printNl.
+f := MyFileFactory newInstance.
+f class printNl.

 "Open the file, and print its state"
-f open.
-f state printNl.
+f := f open.
+f class printNl.

 "Close the file, and print its state"
-f close.
-f state printNl.
+f := f close.
+f class printNl.

And this would be the new output:

~$ gst without_if.st
VirginFile
OpenFile
ClosedFile

Putting it all together, we arrive at the following implementation:

 1Object subclass: MyFile [
 2    MyFile class >> new [
 3        ^super new init.
 4    ]
 5
 6    print [ self read printNl ]
 7
 8    "Abstract methods"
 9    init []
10    open []
11    close []
12    read []
13    write: content []
14]
15
16MyFile subclass: VirginFile [
17    open [ ^OpenFile new ]
18    close [ ^self error: 'File was not opened yet' ]
19    read [ ^self error: 'File was not opened yet' ]
20    write: content [ ^self error: 'File was not opened yet' ]
21]
22
23MyFile subclass: OpenFile [
24    | content |
25
26    init [
27        content := OrderedCollection new.
28    ]
29
30    open [ ^self error: 'File cannot be reopened' ]
31    close [ ^ClosedFile new ]
32    read [ ^content join: (Character lf asString) ]
33    write: newContent [ content add: newContent ]
34]
35
36MyFile subclass: ClosedFile [
37    open [ ^self error: 'File was already closed' ]
38    close [ ^self error: 'File was already closed' ]
39    read [ ^self error: 'Cannot read from a closed file' ]
40    write: content [ ^self error: 'Cannot write to a closed file' ]
41]
42
43Object subclass: MyFileFactory [
44    MyFileFactory class >> newInstance [
45        ^VirginFile new
46    ]
47]
48
49"Our file object"
50f := MyFileFactory newInstance.
51
52"We write some text to it, but not before opening it!"
53f := f open.
54f write: '# Title'.
55f write: 'This is a test file'.
56
57"We read and print the contents of the file"
58f print.
59
60"We close the file, then try reading from it and it all goes to hell 😈"
61f := f close.
62f print.

What’s so nice about this refactor?

  1. You can quickly know how a file object behaves in a given state by looking at the particular class for that state. This is a lot easier than having to read through the code having to do the mental arithmetic of the state transitions and checks.
  2. You can add/change/remove transitions by just changing the “source” state class. It’s a much more localized focus, because you are now only worried about a single (usually) shorter class.
  3. Testing is easier: you set up the initial state by instantiating the corresponding class, and then you can test the state transitions by just calling the methods on that class. In contrast, in the previous example you’d have to transition the MyFile object to the desired state (or do some dirty stuff), and then test from there.

Lisp

We are now going to take a look at a fairly popular sorting algorithm called “quicksort”. If you don’t know about this algorithm, you should, sorry ¯\_(ツ)_/¯. But because I’m a nice guy, I’ll give you a brief overview of how it works. The idea is to pick a “pivot” element from the array (arbitrarily), and partition the rest of the elements in the array into two sub-arrays, according to whether they are less than or greater than the pivot, respectively. The 2 sub-arrays are then sorted recursively. The base case for the recursion is when the array has 1 or 0 elements, in which case the array is already sorted. The algorithm continues to partition and sort the sub-arrays until the entire array is sorted.

The first feeling that comes to mind is “that’s got to involve a lot of if/else statements”. And while that might be true, the fundamental problem here is performing different actions in different situations, which can be solved by using if/else statements:

  1. If the array has 1 or 0 elements, return the array
  2. If the array has more than 1 element, pick a pivot and partition the array into 2 sub-arrays:
    1. If the element is less than the pivot, add it to the left sub-array
    2. If the element is greater than the pivot, add it to the right sub-array

A solution like the one described above could be implemented in Lisp like this:

 1; Create a list of the numbers in `lst` which value is less than `x`
 2(define (lower-than x lst)
 3  (if (null? lst)
 4      '()
 5      (if (< (car lst) x)
 6          (cons (car lst) (lower-than x (cdr lst)))
 7          (lower-than x (cdr lst)))))
 8
 9; Create a list of the numbers in `lst` which value is greater than or equal to
10; `x`
11(define (greater-than-or-equal-to x lst)
12  (if (null? lst)
13      '()
14      (if (>= (car lst) x)
15          (cons (car lst) (greater-than-or-equal-to x (cdr lst)))
16          (greater-than-or-equal-to x (cdr lst)))))
17
18; Create a list of the numbers in `lst` sorted in ascending order using the
19; quicksort algorithm
20(define (quick-sort lst)
21  (if (null? lst)
22      '()
23      (let ((pivot (car lst)))
24        (append
25         (quick-sort (lower-than pivot (cdr lst)))
26         (list pivot)
27         (quick-sort (greater-than-or-equal-to pivot (cdr lst)))))))
28
29(display (quick-sort '(3 6 8 10 1 2 1)))
30(newline)

We can run this code using Guile like this:

~$ guile -s with_if.scm
(1 1 2 3 6 8 10)

OK, now onto the fun part. We can implement the same algorithm without using if/else statements by leveraging one of my favourite features in functional programming: pattern matching. Scheme (which is the dialect of Lisp I’m using in my examples) supports it via its (ice-9 match) module. The idea is to match some expression against a list of patterns, each of which associated to a specific action. First, lets reformulate the solution:

  1. The result of sorting an empty list is an empty list
  2. The result of sorting a non-empty list with a pivot as its first element is another list built from:
    1. The elements that are less than the pivot, sorted
    2. The pivot
    3. The elements that are greater than the pivot, sorted

Now, the implementation might look more confusing for someone who is not familiar with functional programming. If that’s you, I’d recommend you don’t get fixated on every little detail (e.g. all the parenthesis), but rather on how “declarative” and “natural” the code looks:

 1(use-modules (ice-9 match))
 2
 3; Create a list of the numbers in `lst` which value is less than `x`
 4(define (lower-than x lst)
 5  (filter (lambda (y) (< y x)) lst))
 6
 7; Create a list of the numbers in `lst` which value is greater than or equal to
 8; `x`
 9(define (greater-than-or-equal-to x lst)
10  (filter (lambda (y) (>= y x)) lst))
11
12; Create a list of the numbers in `lst` sorted in ascending order using the
13; quicksort algorithm
14(define (quick-sort lst)
15  (match lst
16    ('() '())       ; base case: empty list
17    ((pivot . rest) ; non-empty list, with `pivot` as the first element
18     (let ((lower (lower-than pivot rest))
19           (greater (greater-than-or-equal-to pivot rest)))
20       (append
21        (quick-sort lower)
22        (list pivot)
23        (quick-sort greater))))))
24
25(display (quick-sort '(3 6 8 10 1 2 1)))
26(newline)

Same result of course 🤡:

~$ guile -s without_if.scm
(1 1 2 3 6 8 10)
Mildly interesting

Note that all the functions in both examples are equivalent, which is nice. That’s in part possible because of the convention in functional programming (and especially in Lisp) of writing small functions that do one thing and do it well. But that’s a separate topic.

Ruby

Smalltalk and Lisp are not exactly the most popular languages out there. In my case, I have never ever met anyone who actually used them in production (except maybe the occasional university professor). So I thought it would be a good idea to show another example in a more popular language, like Ruby.

Ruby is a language that has a lot of syntactic sugar, and as I mentioned in the intro one of the most interesting features is its support for metaprogramming, which allows you to customize any class defined in Ruby, and since in Ruby everything is an object, this feature allows you to redefine the language itself to some extent.

true and false, for example, are two different objects of different classes in Ruby (TrueClass and FalseClass, respectively), and both classes inherit from Object (like virtually all other classes in Ruby). That allows us to define two methods for all Ruby objects:

  1. if_then: a method that takes a “block” and executes it if the object receiving the message is true, otherwise it just returns the object (i.e. self).
  2. or_else: same as if_then, but for false objects.
Note

A block is essentially a function that can be passed as an argument to a method. In Ruby, blocks are defined using either curly braces {} or the do...end syntax. Think of them as callbacks.

The implementation is pretty straightforward, short and elegant. We start by changing the Object class to add the new methods so that they are available to all objects:

1class Object
2  def if_then
3    self
4  end
5
6  def or_else
7    self
8  end
9end

After that, we can override these new methods for both true and false objects, so that they behave according to the rules we defined above:

 1class TrueClass
 2  def if_then
 3    yield
 4  end
 5end
 6
 7class FalseClass
 8  def or_else
 9    yield
10  end
11end
Note

The yield keyword is a special keyword in Ruby that allows you to pass a block of code to a method. The block of code is then executed and the result returned.

What can we accomplish by doing this? Let’s try with the sign function, which returns -1 if the number is negative, 0 if it’s zero, and 1 if it’s positive:

1def sign(x)
2  (x > 0).if_then { 1 }.or_else do
3    (x < 0).if_then { -1 }.or_else { 0 }
4  end
5end

Let’s run this code with a few examples:

irb(main):001> sign 0
=> 0
irb(main):002> sign -1
=> -1
irb(main):003> sign -10
=> -1
irb(main):004> sign 100
=> 1

Great, it works! But are we happy? What have we escaped from exactly? We still wrote if, else, etc. albeit in a different form (see the Smalltalk examples again, and the similarity with ifTrue and ifFalse). We’ve also added procedure calls to the mix, which consumes time and space. Ruby in particular has a powerful feature in its syntax which involves inlined if (and unless which is its negation form). Is the example above better than the following one?

1def sign_if(x)
2  return -1 if x < 0
3  return 0 if x == 0
4  return 1 if x > 0
5end

This new example is objectively much more readable, as it resembles the mathematical definition of the function:

$$ \text{sign}(x) = \begin{cases} -1, & \text{if } x < 0 \\ 0, & \text{if } x = 0 \\ 1, & \text{if } x > 0 \end{cases} $$

Performance-wise, the second example is also better (~2x) because it doesn’t involve any additional procedure calls:

irb(main):001> require 'benchmark'
irb(main):002> n = 10_000_000
=> 10000000
irb(main):003* Benchmark.bm do |x|
irb(main):004*   x.report { for i in -n..n; sign i; end }
irb(main):005*   x.report { for i in -n..n; sign_if i; end }
irb(main):006> end
       user     system      total        real
   5.506314   0.008099   5.514413 (  5.527396)
   2.546904   0.002875   2.549779 (  2.555199)

I can only imagine the confusion an LLM might face trying to decipher the if-less case, not just because it’s a non-native approach that looks like a hack, but also because there’s probably no code out there that looks like this, so we’d be fighting a battle in which we already are at a disadvantage.

Closure

So what’s the takeaway of this discussion and these workarounds? Be pragmatic! The challenge of pragmatism is balancing your current needs with your future ones, and trying your best to make the right tradeoffs. And by needs I mean your need to deliver a product that works, guarding your reputation, showing respect for your colleagues that will have to maintain your code, and so on. In summary, being a professional.

The whole point of the Anti-IF Campaign is to avoid the “Let’s Put an IF” syndrome that the author mentions. But that doesn’t mean that you should avoid if/else statements at all costs. In fact, I think that the best way to avoid this syndrome is to use if/else statements when it’s natural, intuitive and “stable” (i.e. when you’re not just making a temporary patch).

Appendix

  1. This post by pozorvlak mentions how heavy repetition of design patterns can signal an incomplete set of features in a language and/or stdlib
  2. There’s also an interesting follow-up discussion of the post above on Hacker News
  3. Smalltalk intro
  4. if is as evil as goto
  5. Philip J. Koopman, Jr. mentions a potential implementation of if conditionals in his book Stack Computers: the new wave, where he suggests that an if expression could be just a subroutine with a conditional return as its first instruction. Keep in mind that this is a book published in the 80’s, and stack machines aren’t exactly popular these days (sadly).