OOP Versus Functional Decomposition

Introduction

We can compare procedural (functional) decomposition and object-oriented decomposition using the classic example of implementing operations for a small expression language. In functional programming, we typically break programs down into functions that perform some operation. In OOP, we typically break programs down into classes that give behavior to some kind of data.

We show that the two approaches largely lay out the same ideas in exactly opposite ways, and which way is “better” is either a matter of taste or depends on how software might be changed or extended in the future. We then consider how both approaches deal with operations over multiple arguments, which in many object-oriented languages requires a technique called double (multiple) dispatch in order to stick with an object-oriented style.

The Basic Set-Up

The following problem is the canonical example of a common programming pattern, and, not coincidentally, is a problem we have already considered a couple times in the course. Suppose we have:

This problem leads to a conceptual matrix (two-dimensional grid) with one entry for each combination of variant and operation:

  eval toString hasZero
Int      
Add      
Negate      

No matter what programming language you use or how you approach solving this programming problem, you need to indicate what the proper behavior is for each entry in the grid. Certain approaches or languages might make it easier to specify defaults, but you are still deciding something for every entry.

The Functional Approach

In functional languages, the standard style is to do the following:

Note this approach is really just procedural decomposition: breaking the problem down into procedures corresponding to each operation.

This ML code shows the approach for our example: Notice how we define all the kinds of data in one place and then the nine entries in the table are implemented “by column” with one function for each column:

exception BadResult of string

datatype exp =
    Int of int
    | Negate of exp
    | Add of exp * exp
          
fun eval e =
    case e of
        Int _ => e
      | Negate e1 => (case eval e1 of
                          Int i => Int (~i)
                        | _ => raise BadResult "non-int in negation")
      | Add(e1,e2) => (case (eval e1, eval e2) of
                          (Int i, Int j) => Int (i+j)
                        | _ => raise BadResult "non-ints in addition")
        
fun toString e =
    case e of
        Int i => Int.toString i
      | Negate e1 => "-(" ^ (toString e1) ^ ")"
      | Add(e1,e2) => "(" ^ (toString e1) ^ " + " ^ (toString e2) ^ ")"
                  
fun hasZero e =
    case e of
        Int i => i=0
      | Negate e1 => hasZero e1
      | Add(e1,e2) => (hasZero e1) orelse (hasZero e2)

The Object-Oriented Approach

In object-oriented languages, the standard style is to do the following:

Note this approach is a data-oriented decomposition: breaking the problem down into classes corresponding to each data variant.

Here is the Ruby code, which for clarity has the different kinds of expressions subclass the Exp class. In a statically typed language, this would be required and the superclass would have to declare the methods that every subclass of Exp defines — listing all the operations in one place. Notice how we define the nine entries in the table “by row” with one class for each row.

class Exp
# could put default implementations or helper methods here
end

class Int < Exp
  attr_reader :i
  def initialize i
    @i = i
  end
  def eval
    self
  end
  def toString
    @i.to_s
  end
  def hasZero
    i==0
  end
end

class Negate < Exp
  attr_reader :e
  def initialize e
    @e = e
  end
  def eval
    Int.new(-e.eval.i) # error if e.eval has no i method (not an Int)
  end
  def toString
    "-(" + e.toString + ")"
  end
  def hasZero
    e.hasZero
  end
end

class Add < Exp
  attr_reader :e1, :e2
  def initialize(e1,e2)
    @e1 = e1
    @e2 = e2
  end
  def eval
    Int.new(e1.eval.i + e2.eval.i) # error if e1.eval or e2.eval have no i method
  end
  def toString
    "(" + e1.toString + " + " + e2.toString + ")"
  end
  def hasZero
    e1.hasZero || e2.hasZero
  end

The Punch-Line

So we have seen that functional decomposition breaks programs down into functions that perform some operation and object-oriented decomposition breaks programs down into classes that give behavior to some kind of data. These are so exactly opposite that they are the same — just deciding whether to lay out our program “by column” or “by row.” Understanding this symmetry is invaluable in conceptualizing software or deciding how to decompose a problem. Moreover, various software tools and IDEs can help you view a program in a different way than the source code is decomposed. For example, a tool for an OOP language that shows you all methods foo that override some superclass’ foo is showing you a column even though the code is organized by rows.

So, which is better? It is often a matter of personal preference whether it seems “more natural” to lay out the concepts by row or by column, so you are entitled to your opinion. What opinion is most common can depend on what the software is about. For our expression problem, the functional approach is probably more popular: it is “more natural” to have the cases for eval together rather than the operations for Negate together. For problems like implementing graphical user interfaces, the object-oriented approach is probably more popular: it is “more natural” to have the operations for a kind of data (like a MenuBar) together (such as backgroundColor, height, and doIfMouseIsClicked rather than have the cases for doIfMouseIsClicked together (for MenuBar, TextBox, SliderBar, etc.). The choice can also depend on what programming language you are using, how useful libraries are organized, etc.

Reference

All content taken from Programming Languages, Part C on Coursera.