PC204 Lecture 8

Conrad Huang

conrad@cgl.ucsf.edu

Genentech Hall, N453A

Topics

  • Homework review
  • Review of OOP
  • Inheritance
  • Polymorphism
  • Introspection
  • Debugging

Homework Review

  • Solution:
    • 7.1 - Rectangle class
    • 7.2 - __add__ method
  • Preview of inheritance:
    • 7.1a - Rectangle class
    • 7.2a - __add__ method

Review of OOP

  • Many methodologies have been developed to help programmers.
  • Object-oriented programming is about grouping data and functions together into units (objects) that can be manipulated using an external interface and whose self-consistency is maintained by the internal implementation.

     

  • The ultimate goal is to manage and minimize complexity.

Classes and Instances

  • Suppose we have class C1 and instances myc1 and myc1a:
    class C1(object):
        "C1 doc"
        def f1(self):
            # do something with self
        def f2(self):
            # do something with self
    
    # create C1 instances
    myc1 = C1()
    myc1a = C1()
    
    # call f2 method on one instance
    myc1.f2()
    

Classes and Instances (cont.)

    class C1(object):
        "C1 doc"
        def f1(self):
            # do something with self
        def f2(self):
            # do something with self
    
    # create C1 instances
    myc1 = C1()
    myc1a = C1()
    
    # call f2 method on one instance
    myc1.f2()
    
  • Executing the class statement creates the C1 class
    • Note that C1 is actually a variable referencing a class object (analogous to an import statement creating a variable referencing a module object).
    • Note also that the class object is defined with both a class attribute, __doc__, and methods, f1 and f2.
    • In Python 2, object is a built-in (predefined) class.
    • In Python 3, the “(object)” part should be omitted.

Classes and Instances (cont.)

  • Creating an instance creates a new attribute namespace.
  • Each instance has its own attribute namespace, but they all share the same class namespace(s) as well.
  • Both instance and class attributes, as well as methods, may be accessed using the instance.name syntax.

Accessing Attributes

  • Setting an instance attribute can potentially shadow a class attribute:

Accessing Attributes (cont.)

  • Once shadowed, an attribute name always refers to the instance attribute and not the class attribute:
    >>> print(myc1.f1)
    hello
    >>> print(myc1.f2)
    <bound method C1.f2 of <__main__.C1 object at 0x1401d6b50>>
    >>> print(myc1.__doc__)
    C1 doc
    >>> myc1.f1()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: 'str' object is not callable
    

Calling Methods

  • Methods may be called in two different ways:
    instance.method(arguments...)
    class.method(instance, arguments...)
    
  • instance.method() is preferable over class.method(instance).
    • The former suggests that we are asking the instance to do something, whereas the latter suggests that the class is doing something to the instance.
    • Although the code works in both cases, the explicit naming of a class in the statement suggests that the method is defined in the class when it might actually be inherited from a base class.

Class Attributes

    >>> C1.count = 12
    >>> print(C1.count)
    12
    >>> C1.f1
    <unbound method C1.f1>
    >>> C1.f1(myc1)
    >>> print(C1.__doc__)
    C1 doc
    >>> C1.__doc__ = "new documentation"
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    AttributeError: attribute '__doc__' of 'type' objects is not writable
    >>> help(C1)
    …
    
  • Class attributes may be looked up via the instances, but they cannot be modified using the instance.name syntax. (An instance attribute gets created and shadows the class attribute.)
  • To change class attributes, use the class object.
  • Some built-in class attributes, e.g., __doc__, are read-only and cannot be modified.

Attribute Pitfall

    >>> class C1:
    ...     count = 12
    ...
    >>> myc1 = C1()
    >>> print(myc1.count)
    12
    >>> myc1.count = 20
    >>> print(myc1.count)
    20
    >>> print(C1.count)
    12
    
  • Attribute lookup and assignment are not symmetrical.

Object Oriented Programming

  • Modules, classes and instances - divide and conquer
    • Abstraction
    • Encapsulation
    • Unit testing
  • Inheritance and polymorphism - minimize and reuse
    • Common interface
    • Specialization
    • Generic functions

Inheritance

  • “Inheritance is the ability to define a new class that is a modified version of an existing class.” – Allen Downey, Think Python
  • “A relationship among classes, wherein one class shares the structure or behavior defined in one (single inheritance) or more (multiple inheritance) other classes. Inheritance defines a “kind of” hierarchy among classes in which a subclass inherits from one or more superclasses; a subclass typically augments or redefines the existing structure and behavior of superclasses.” – Grady Booch, Object-Oriented Design

Inheritance (cont.)

  • A conceptual example of a class hierarchy:

Base Class vs. Derived Class

  • A base class defines a set of methods and a derived class can either inherit (use the same implementation) or override (replace the implementation).

Inheritance Syntax

    class C1(object):
        "C1 doc"
        def f1(self):
            # do something with self
        def f2(self):
            # do something with self
    
    # create a C1 instance
    myc1 = C1()
    
    # call f2 method
    myc1.f2()
    
  • The syntax for inheritance was already introduced during class declaration.
    • C1 is the name of the subclass.
    • object is the name of the superclass.
    • For multiple inheritance, superclasses are declared as a comma-separated list of class names between the parentheses.

Inheritance Syntax (cont.)

    stack.py

    class Stack(list):
        "LIFO data structure"
        def push(self, element):
            self.append(element)
        # Might also have used:
        #push = list.append
    
    st = Stack()
    print("Push 12, then 1")
    st.push(12)
    st.push(1)
    print("Stack content", st)
    print("Popping last element", st.pop())
    print("Stack content now", st)
    
  • Supposed we want to use the Python list class to implement a stack data structure. (Superclasses may be either Python- or user-defined classes.) Elements may be added to and removed from a stack using push and pop operations, respectively. The last element added by push is the first to be removed by pop.
  • The Python list class already has a method, pop, for removing the last element of the list, but there is no method named push.
  • To implement a stack, all we need to do is start with the Python list class and add a push method that adds an element to the end of the list, because pop would then have the proper semantics, i.e., adding the newest element at the end of the list guarantees that the next call to pop will remove it first.

Inheritance Syntax (cont.)

  • A subclass inherits all the methods of its superclass.
  • A subclass can define new methods not in the superclass.
  • A subclass can override (replace or augment) methods defined by the superclass.
    • Defining a method with the same name shadows the superclass method.
    • Although not enforced by Python, keeping the same arguments (as well as pre- and post-conditions) for overridden methods is highly recommended.
    • Calling the superclass method is a simple way to get both the original functionality and add custom behavior.
  • A subclass can serve as the superclass for other classes.

Overriding a Method

    calculator.py

    class Stack(list):
        push = list.append
    
    class Calculator(Stack):
        def __init__(self):
            Stack.__init__(self)
            self.accumulator = 0
        def __str__(self):
            return str(self.accumulator)
        def push(self, value):
            Stack.push(self, value)
            self.accumulator = value
    
    c = Calculator()
    c.push(10)
    print(c)
    
  • __init__ is frequently overridden because many subclasses need to both:
    • let their superclasses initialize their data, and
    • initialize their own data,
    usually in that order.
  • When defining subclass methods, be careful not to accidentally override superclass methods, or strange bugs will occur because your methods are called in place of the superclass methods.

Multiple Inheritance

  • Python supports multiple inheritance.
  • In the class statement, replace the single superclass name with a comma-separated list of superclass names.
  • When looking up an attribute, Python will look for it in “method resolution order” (MRO) which is approximately left-to-right, depth-first.
  • There are (sometimes) subtleties that make multiple inheritance tricky to use, e.g., superclasses that derive from a common super-superclass.
  • For most uses, single inheritance is good enough.

Class Diagrams

  • Class diagrams are visual representations of the class hierarchy, i.e., the relationships among classes.
    • They are similar in spirit to entity-relationship diagrams, unified modeling language, etc. in that they help implementers in understanding and documenting application/library architecture.
    • They are more useful in bigger projects where there are many classes and attributes.
    • They are also very helpful (along with documentation) when trying to understand unfamiliar code.

Polymorphism

  • “Functions that can work with several types are called polymorphic.” – Downey, Think Python
  • “The primary usage of polymorphism in industry (object-oriented programming theory) is the ability of objects belonging to different types to respond to method, field, or property calls of the same name, each one according to an appropriate type-specific behavior. The programmer (and the program) does not have to know the exact type of the object in advance, and so the exact behavior is determined at run time (this is called late binding or dynamic binding).” – Wikipedia

Polymorphism (cont.)

  • Downey: Two objects with different APIs (note the different solid colors for the objects) may be passed as argument to the same polymorphic function.

Polymorphism (cont.)

  • Wikipedia: Two objects with the same API (note the same solid colors) but different implementations (note the different patterns) may be passed to the same generic function, which can manipulate both objects in the same manner.

Polymorphism (cont.)

  • The critical feature of polymorphism is a shared interface.
    • Using the Downey definition, we present a common interface where the same function may be used regardless of the argument type.
    • Using the Wikipedia definition, we require that polymorphic objects share a common interface that may be used by a (generic) function to manipulate the objects regardless of class.

Polymorphism (cont.)

  • Why is polymorphism useful?
    • By reusing the same interface for multiple purposes, polymorphism reduces the number of “things” we have to remember.
    • It becomes possible to write a “generic” function that perform a particular task, e.g., sorting, for objects from many different classes (instead of one function for objects of each class)

Polymorphic Functions

  • To define a polymorphic function that accepts multiple types of data requires the function either:
    • be able to distinguish among the different types that it should handle, or
    • be able to use other polymorphic functions, methods or syntax to manipulate any of the given types.

Polymorphic Functions (cont.)

    dispatch.py

    def what_is_this(data):
        if isinstance(data, str):
            # In Python 2, we would use basestring instead of str
            return "instance of string"
        elif hasattr(data, "__class__"):
            return ("instance of %s" %
                    data.__class__.__name__)
        raise TypeError("unknown type: %s" %
                        str(data))
    
    class NC(object): pass
    class OC: pass
    
    print(what_is_this("Hello"))
    print(what_is_this(12))
    print(what_is_this([1, 2]))
    print(what_is_this({12:14}))
    print(what_is_this(NC()))
    print(what_is_this(OC()))
    
  • Polymorphic functions can use type-based dispatching, i.e., handling different arguments by first identifying the argument type or class, and then applying the appropriate operation for that type or class.
  • Python provides several convenient functions:
    • isinstance function
    • hasattr function
    • __class__ attribute

Polymorphic Functions (cont.)

    histogram.py

    def histogram(s):
        d = dict()
        for c in s:
            d[c] = d.get(c, 0) + 1
        return d
    
    print(histogram("aabc"))
    print(histogram([1, 2, 2, 5]))
    print(histogram(("abc", "abc", "xyz")))
    
  • Polymorphic functions can also use built-in Python polymorphism.
  • Python uses the same syntax for a number of data types, so we can implement polymorphic functions for these data types if we use the right syntax.
  • For example, the for statement may be used to loop over iterables, e.g., characters in strings, elements in lists and tuples, or keys in dictionaries.

Polymorphic Classes

  • Classes that share a common interface
    • The common interface among multiple classes is the intersection of all their APIs
    • A function implemented using only the common interface will work with objects from any of the classes.
  • Although Python does not require it, a simple way to achieve this is to have the classes derive from a common superclass.
    • To maintain polymorphism, methods overridden in the subclasses must keep the same arguments as the method in the superclass.

Polymorphic Classes (cont.)

    inheritance.py

    class InfiniteSeries(object):
        def next(self):
            raise NotImplementedError("next")
    class Fibonacci(InfiniteSeries):
        def __init__(self):
            self.n1, self.n2 = 1, 1
        def next(self):
            n = self.n1
            self.n1, self.n2 = self.n2, self.n1 + self.n2
            return n
    class Geometric(InfiniteSeries):
        def __init__(self, divisor=2.0):
            self.n = 1.0 / divisor
            self.nt = self.n / divisor
            self.divisor = divisor
        def next(self):
            n = self.n
            self.n += self.nt
            self.nt /= self.divisor
            return n
    def print_series(s, n=10):
        for i in range(n):
            print("%.4g" % s.next(), end=' ')
        print()
    
    print_series(Fibonacci())
    print_series(Geometric(3.0))
    print_series(InfiniteSeries())
    
  • The superclass defining the interface often has no implementation and is called an abstract base class.
  • Subclasses of the abstract base class override interface methods to provide class-specific behavior.
  • A generic function can manipulate all subclasses of the abstract base class.

Polymorphic Classes (cont.)

  • In our example, the abstract base class, InfiniteSeries defines the common interface (the __init__ and next methods).
  • The two subclasses (Fibonacci and Geometric overrode both methods so they have different behavior.
  • If a subclass does not override a base class method, then it inherits the base class behavior.
    • If the base class behavior is acceptable, the writer of the subclass does not need to do anything.
    • There is only one copy of the code so, when a bug is found in the inherited method, only the base class needs to be fixed.
  • Note that the generic function, e.g. our print_series function, must use s.next() to call the method because it does not know what class s belongs to.

Cards, Decks and Hands

  • Two possible class diagrams for example in Chapter 18 and Exercise 18.6 (homework assignment 8.1):
  • Starred arrows denote has-a relationships (ownership) while normal arrows denote is-a relationships (inheritance).
  • Which design is better?

Complexity Trade-off

  • Advantages of more detailed design:
    • Each class corresponds to a real concept.
    • It should be possible to write a polymorphic function to play cards using only Game and Hand interfaces.
    • It should be easier to implement other card games.
  • Disadvantages:
    • Using more classes means having more things to remember.
    • The design on right requires multiple inheritance (although in this case it should not be an issue because the class hierarchy is very simple).
  • Which is better? It depends.
    • Do you plan to implement more games?
    • Do you need it fast (like handing in homework)?
    • In general, look to the future, but not too far into the future. With a reasonable design, at worst, you should still be able to refactor to reuse the code you've written.

Introspection

  • Python is capable of introspection, the ability to examine an object at run-time without knowing its class and attributes a priori.
  • Given an object, you can:
    • get the names and values of its attributes (including inherited ones),
    • get its class, and
    • check if it is an instance of a class or a subclass of a class.
  • Using these tools, you can easily write polymorphic functions, which are particularly useful when debugging.

Introspection (cont.)

    introspection.py

    def tell_me_about(data):
        print(str(data))
        print(" Id:", id(data))
        if isinstance(data, str):
            # In Python 2, we would use basestring instead of str
            print(" Type: instance of string")
        elif hasattr(data, "__class__"):
            print(" Type: instance of %s" % data.__class__.__name__)
        else:
            print(" Type: unknown type")
        if hasattr(data, "__getitem__"):
            like = []
            if hasattr(data, "extend"):
                like.append("list-like")
            if hasattr(data, "keys"):
                like.append("dict-like")
            if like:
                print(" %s" % ", ".join(like))
    
    tell_me_about({12:14})
    class NC(object): pass
    nc = NC()
    nc_copy = nc
    tell_me_about(nc)
    tell_me_about(nc_copy)
    tell_me_about(NC())
    
  • Introspect an object and print its unique identifier and type:
    {12: 14}
     Id: 5370941216
     Type: instance of dict
     dict-like
    <__main__.NC object at 0x1401d6410>
     Id: 5370635280
     Type: instance of NC
    <__main__.NC object at 0x1401d6410>
     Id: 5370635280
     Type: instance of NC
    <__main__.NC object at 0x1401d6490>
     Id: 5370635408
     Type: instance of NC
    

Introspection (cont.)

    introspection.py

    def list_attributes(obj):
        for attr_name in dir(obj):
            print(" %s:" % attr_name, end=' ')
            value = getattr(obj, attr_name)
            if callable(value):
                print("function/method")
            else:
                print(value)
    list_attributes(list)
    
  • Introspect an object and print all its attribute values:
     __add__: function/method
     __class__: function/method
     __contains__: function/method
     __delattr__: function/method
     __delitem__: function/method
     __delslice__: function/method
     __doc__: list() -> new empty list
    list(iterable) -> new list initialized from iterable's items
     __eq__: function/method
     __format__: function/method
     __ge__: function/method
     __getattribute__: function/method
     __getitem__: function/method
     __getslice__: function/method
     __gt__: function/method
     __hash__: None
     __iadd__: function/method
     __imul__: function/method
     __init__: function/method
     __iter__: function/method
     __le__: function/method
     __len__: function/method
     __lt__: function/method
     __mul__: function/method
     __ne__: function/method
     __new__: function/method
     __reduce__: function/method
     __reduce_ex__: function/method
     __repr__: function/method
     __reversed__: function/method
     __rmul__: function/method
     __setattr__: function/method
     __setitem__: function/method
     __setslice__: function/method
     __sizeof__: function/method
     __str__: function/method
     __subclasshook__: function/method
     append: function/method
     count: function/method
     extend: function/method
     index: function/method
     insert: function/method
     pop: function/method
     remove: function/method
     reverse: function/method
     sort: function/method
    

Debugging

  • A major source of programming error is failure to maintain consistency among a set of data, e.g., attributes of an instance.
  • To minimize this problem, we should identify invariants, which are data consistency requirements, and liberally test whether invariants hold in our methods.
  • Two special cases of tests are pre-conditions, which are tests made just after a method is called, and post-conditions, which are tests made just before a method returns. Together, they (try to) guarantee that an instance is in a consistent state before the method executes (detection of data corruption by others) and after (detection of data corruption by the method itself).
  • Wide use of pre- and post-conditions helps developers detect inconsistencies early, and minimize red herrings that derive from propagation of bad data.

Debugging (cont.)

    conditions.py

    class Rectangle(object):
        """A rectangle class where dimensions
        are kept in a sorted array."""
    
        def __init__(self, w, h):
            self._set_dims(w, h)
    
        def _set_dims(self, w, h):
            self.width = w
            self.height = h
            if w < h:
                self.dim = [w, h]
            else:
                self.dim = [h, w]
    
        def set_dims(self, w, h):
            assert self._check_invariant()
            self._set_dims(w, h)
            assert self._check_invariant()
    
        def set_width(self, w):
            assert self._check_invariant()
            self.width = w
            assert self._check_invariant()
    
        def smallest_dimension(self):
            assert self._check_invariant()
            return self.dim[0]
    
        def _check_invariant(self):
            if self.dim[0] > self.dim[1]:
                return False
            if self.width not in self.dim:
                return False
            if self.height not in self.dim:
                return False
            return True
    
    r = Rectangle(10, 20)
    print(r.smallest_dimension())
    r.set_dims(15, 20)
    print(r.smallest_dimension())
    r.set_width(30)
    print(r.smallest_dimension())
    
  • A contrived example of invariants, pre- and post-conditions
    10
    15
    Traceback (most recent call last):
      File "conditions.py", line 41, in 
        r.set_width(30)
      File "conditions.py", line 35, in set_width
        assert self._check_invariant()
    AssertionError
    

Homework

  • 8.1 - Rank poker hands
  • 8.2 - Write a short description of your final project
  • (Optional) Challenge 2 - “Six Degrees of UCSF Graduate Programs”