CS 251: Programming Languages Fall 2015 Ruby/OOP Summary, Part 2
Total Page:16
File Type:pdf, Size:1020Kb
CS 251: Programming Languages Fall 2015 Ruby/OOP Summary, Part 2 These notes are adapted from CSE 341 at the University of Washington, by Dan Grossman. Contents OOP Versus Functional Decomposition ................................ 1 Extending the Code With New Operations or Variants ...................... 4 Binary Methods With Functional Decomposition .......................... 6 Binary Methods in OOP: Double Dispatch .............................. 7 Multimethods ................................................ 11 Multiple Inheritance ............................................ 11 Mixins ..................................................... 13 Java/C#-Style Interfaces ......................................... 16 Abstract Methods .............................................. 16 Introduction to Subtyping ........................................ 17 A Made-Up Language of Records .................................... 18 Wanting Subtyping ............................................. 19 The Subtyping Relation .......................................... 19 Depth Subtyping: A Bad Idea With Mutation ........................... 20 The Problem With Java/C# Array Subtyping ........................... 21 Function Subtyping ............................................. 22 Subtyping for OOP ............................................. 25 Covariant self/this ............................................. 26 Generics Versus Subtyping ........................................ 26 Bounded Polymorphism .......................................... 29 Optional: Additional Java-Specific Bounded Polymorphism ................... 30 OOP Versus Functional Decomposition 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: 1 • 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 deter- mining 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") 2 | 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 3 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 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 4 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. Extending the Code With New Operations or Variants The choice between \rows" and \columns" becomes less subjective if we later extend our program by adding new data variants or new operations. Consider the functional approach. Adding a new operation is easy: we can implement a new function without editing any existing code. For example, this function creates a new expression that evaluates to the same result as its argument but has no negative constants: fun noNegConstants e = case e of Int i => if i < 0 then Negate (Int(~i)) else e | Negate e1 => Negate(noNegConstants e1) | Add(e1,e2) => Add(noNegConstants e1, noNegConstants e2) On the other hand, adding a new data variant, such as Mult of exp * exp is less pleasant. We need to go back and change all our functions to add a new case.