The Complexity of Object-Oriented Design

July 11, 2014 📬 Get My Weekly Newsletter

I can’t say what a codebase designed to Alan Kay’s idea of “object-oriented” might look like. I can say what your average developer (including myself) actually creates using object-oriented languages, tools, and techniques. The result is a constant battle to tame complexity. I’m going to lay out one source of that complexity, because it’s baked-in to object-orientation, and I debate that it provides any utility in making programs easy to understand or change.

Consider a procedural language in which no global symbols are possible:

def salutation(first_name,last_name)
  if first_name != nil
    "Hey #{first_name}!"
  else
    "Hello, #{last_name}"
  end
end

salutation("Dave","Copeland") # => Hey Dave
salutation(nil,"Jones")       # => Hello, Jones

Because there are no global symbols, we can easily (and totally) understand this routine. Everything it requires to do its job is passed as parameter, and every affect it has is part of its return value.

Such a language would be unusable at any real complexity, because we could not decompose logic into smaller re-usable routines. Consider if we are creating a message for someone:

def create_message(first_name,last_name,message,from)
  salutation = if first_name != nil
                 "Hey #{first_name}!"
               else
                 "Hello, #{last_name}"
               end
  %{
#{salutation},

#{message}

           Sincerely,

           #{from}
}
end

This routine, like salutation, is still simple to understand. Everything it needs to do its job is passed as a parameter and its entire affect is described in its return value.

But, since we don’t have global symbols (or any other obvious way to share logic), we’ve had to duplicate salutation. Although our hypothetical language encourages simple design, it’s not usable in its current state.

If we could wrap up the salutation logic, along with the data it needed, into a single package, that could allow re-use.

Objects: Data & Functionality?

In an object-oriented language, we have the ability to associate functionality with data, so we might logically have the first_name and last_name in some sort of object, and that object will implement the salutation method.

class Person
  def salutation
    if first_name != nil
      "Hey #{first_name}!"
    else
      "Hello, #{last_name}"
    end
  end
end

Now, our create_message doesn’t need to reproduce the salutation logic, but can use it from the new person object:

def create_message(person,message,from)
  %{
#{person.salutation},

#{message}

           Sincerely,

           #{from}
}
end

This seems good, right? We still don’t need global symbols, and we’ve found a way to encapsulate and re-use logic.

Why are global symbols bad?

Suppose that instead of creating objects, we had the ability to define a global symbol. We could re-use salutation by making in global, meaning that create_message could be implemented as follows:

def create_message(first_name,last_name,message,from)
  %{
#{salutation(first_name,last_name},

#{message}

           Sincerely,

           #{from}
}
end

We’ve successfully re-used salutation, but look at how complex create_message has become! Before, all input to create_message was in its parameter list. Now, its inputs are the parameter list and every global symbol.

Consider how we might send a message:

def send_message(email_addresses,message)
  for email_address in email_addresses
    email(email_address,message)
  end
end

In addition to having all global state as its input, send_message’s output is also anything available in global state. send_message returns nothing, but has an affect on the outside world nontheless.

All this means that any routine that has access to a shared global state is going to be more complex than one that doesn’t, and that, without discipline, a program making use of shared global state will be harder to understand, test, and modify.

This gives us a new insight into our object-oriented solution. Although send_message retained its simplicity, we’ve actually created a miniature global state in our Person class.

Objects Are Their Own Shared Global State

Our Person class from above omitted a few details, namely where first_name and last_name came from. In most OO languages, you’d assume they are instance variables, so let’s add a bit more code to make this a Ruby class.

class Person
  def initialize(first_name,last_name)
    @first_name = first_name
    @last_name  = last_name
  end

  def salutation
    if @first_name != nil
      "Hey #{@first_name}!"
    else
      "Hello, #{@last_name}"
    end
  end
end

This is now a working Ruby implementation of our Person class. Look again at salutation. What are its inputs? It takes no parameters, but is freely able to reference instance variables. So, its inputs are every instance variable of the object. Currently, there are only two, but it’s entirely possible, and likely, that we’ll have objects with many more instance variables, and more functionality.

Let’s add the ability to change a person’s name, which is a reasonable operation to provide (I’m showing the entire class instead just of the changes):

class Person
  def initialize(first_name,last_name)
    @first_name = first_name
    @last_name  = last_name
  end

  def salutation
    if @first_name != nil
      "Hey #{@first_name}!"
    else
      "Hello, #{@last_name}"
    end
  end

  def first_name=(new_first_name)
    @first_name = new_first_name
  end

  def last_name=(new_last_name)
    @last_name = new_last_name
  end
end

first_name= and last_name= take a parameter, but they don’t return a (useful) value. The point of those methods is to change the internal state of the object, meaning that their affects are not part of their return value.

This is the same problem we had with global variables! Certainly, instance variables, due to their natural proximity to the code that can access them, create less of a mess, but they still create the same type of complexity.

Now add inheritance and mixins to your toolbelt, and you have even more inputs and outputs to each routine.

This means that object-oriented designs encourage the creation of routines that have multiple, implicit inputs and have multiple, implicit outputs. Object-oriented design, by its very nature, encourages writing complex routines.

To combat this complexity, we have had to develop a lot of “rules”, “laws”, and “principles”, and their application is a source of constant debate. Even for someone with years of experience, it can be difficult to know how to best-factor an object-oriented codebase.

Let’s go back to the problem we were originally trying to solve.

Remove All Implicit State

I’ve been using “global” a lot, but what we really mean is “implicit”. It’s the ability of a routine to access symbols outside its scope that is the source of complexity here. So let’s go back to our original routines and see how else we could solve the problem of sharing code, but without introducing implicit state.

Here are the two routines again:

def salutation(first_name,last_name)
  if first_name != nil
    "Hey #{first_name}!"
  else
    "Hello, #{last_name}"
  end
end

def create_message(first_name,last_name,message,from)
  salutation = if first_name != nil
                 "Hey #{first_name}!"
               else
                 "Hello, #{last_name}"
               end
  %{
#{salutation},

#{message}

           Sincerely,

           #{from}
}
end

Clearly, create_message needs to access the logic in salutation, so let’s allow that. I’ll do this using valid Ruby syntax, where &foo as a parameter denotes a passed function and &method(:foo) turns a function into a passable function.

def salutation(first_name,last_name)
  if first_name != nil
    "Hey #{first_name}!"
  else
    "Hello, #{last_name}"
  end
end

def create_message(first_name,last_name,message,from,&salutation)
  %{
#{salutation.(first_name,last_name},

#{message}

           Sincerely,

           #{from}
}
end

create_message("Dave","Copeland","Nice blog post!","Yourself",method(&salutation))

Now, we’ve re-used our logic, and all the routines in question still maintain a single source of input and a single destination for output. create_message has gotten slightly more complex, due to the additional parameter, but it’s also lost complexity due to being able to re-use salutation.

Can we build an entire system like this? The functional programmers say we can (and they have certainly proved this). Might be something to think about.