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:
- Expressions for a small “language” such as for arithmetic
- Different variants of expressions, such as integer values, negation expressions, and addition expressions
- Different operations over expressions, such as evaluating them, converting them to strings, or determining if they contain the constant zero in them
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:
- Define a datatype for expressions, with one constructor for each variant. (In a dynamically typed language, we might not give the datatype a name in our program, but we are still thinking in terms of the concept. Similarly, in a language without direct support for constructors, we might use something like lists, but we are still thinking in terms of defining a way to construct each variant of data.)
- Define a function for each operation.
- In each function, have a branch (e.g., via pattern-matching) for each variant of data. If there is a default for many variants, we can use something like a wildcard pattern to avoid enumerating all the branches.
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:
- Define a class for expressions, with one abstract method for each operation. (In a dynamically typed language, we might not actually list the abstract methods in our program, but we are still thinking in terms of the concept. Similarly, in a language with duck typing, we might not actually use a superclass, but we are still thinking in terms of defining what operations we need to support.)
- Define a subclass for each variant of data.
- In each subclass, have a method definition for each operation. If there is a default for many variants, we can use a method definition in the superclass so that via inheritance we can avoid enumerating all the branches.
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.