Inheritance This is the first of three lectures on inheritance. This lecture discusses the general principles involved in defining classes in an inheritance hierarchy and using the hierarchy to find attributes of objects (for both methods and data). It focuses on the rules needed to understand SINGLE-inheritance, showing an example using counters. The second lecture discusses multiple inheritance and generalizes these rules; it actually includes code that illustrates how class objects (not objects constructed from classes aka instances) refer to each other in an inheritance hierarchy, and how attributes are found by Python in complicated inheritance hierarchies. The third lecture discusses various concrete uses of inheritance (both single and multiple) and how we can use inheritance to simplify real programming. Just as inheritance (of money) makes the life of descendants easier, inheritance (of attributes: methods and data) makes writing derived (descendant) classes easier. ------------------------------------------------------------------------------ Class Inheritance in Python At present we know about defining classes and constructing objects that are instances of classes (and assigning attributes in both a class and its instances). We have explored various relationships between an object and the class from which it was constructed (including adding attributes to objects after they are constructed and adding attributes to classes after they are defined). We have studied the Fundamental Equation of Object-Oriented Programming (FEOOP) to determine how Python locates attributes for objects, mostly meaning how methods are called on objects: by first trying to locate the attribute in the object itself, and if that fails, locating the attribute in the class from which the object was constructed (passing the main object to self). Objects mostly store their own state attributes (data), while the classes they are constructed from mostly store their behavior attributes (methods): all the methods are typically shared by all objects of that class. So, objects keep their own state but typically share the methods that operate on them; sometimes, though, objects can store special methods that apply only to them. Also, class objects can store state that is shared among its objects: recall the example of a person class storing a fingers attribute of 10, while individual people objects can store a fingers attribute if they have fewer or more than 10 fingers. To get to full object-oriented programming, we must also learn how to define derived classes from base classes (aka create subclasses from superclasses) and learn how objects constructed from these derived/sub classes behave in regards to their inherited state and behavior: specifically, we will learn that all classes are defined in an inheritance hierarchy and learn how finding attributes of objects is generalized: 1) looking for an attribute in the namespace of instance objects 2) looking for an attribute in the namespace of the class the object was constructed from (we already know these two rules) 3) searching the classes in the inheritance hierarchy, looking in the namespaces of each, in turn, until the attribute is found or there are no more classes to examine. Fundamentally, what inheritance is about is writing small derived classes that reuse attributes from base classes in a natural way. The attribute location process is captured by augmenting the meaning of The Fundamental Equation of Object-Oriented Programming for class inheritance hierarchies. Below we discuss a simple extension for single-inheritance, which is the most common kind of inheritance -the only kind available in Java; in the next lecture we will discuss the more complicated but complete extension for multiple inheritance. We can define a trivial class with no state/behavior (not even an __init__ method) in Python by writing just class C: pass Note the lack of parentheses after the class name C (just a colon). This means that the class C is implicitly a class derived from the "object" class (yes, there is a class named "object" in Python: try x = object() and print(x)); the object class acts as the root (think trees) of Python's inheritance hierarchy. The object class itself is defined in the builtins module and serves as the base class, by default, for any classes defined with no explicitly specified base class. It also contains import standard methods, like __setattr__. The class C doesn't define any standard methods (e.g., __repr__, __setattr__, etc.). Yet, we can call the repr function (which calls the __repr__ method) on objects constructed from classes that don't define __repr___ and Python will still find a __repr__ method to call. We can create attributes for objects constructed from this class (e.g., o = C() and then o.a = 1) and Python will still find a __setattr__ method to call. It can find these methods, because if we do not define such methods in our classes, they are inherited; when we do define a method like __repr__ or __setattr__ in a derived class, it OVERRIDES (see this term defined and explained below) the __repr__ or __setattr__ methods inherited from the "object" class. So, the standard __setattr__ method that we have studied is defined in the object class; it is called, because of inheritance, if we don't define a __setattr__ method in any class that we write. If we want to specify one or more different base classes when defining a class, we must put their names between the parentheses, separated by commas. So, the meaning of class C: -with no parentheses- is the same as class C(object): pass In both cases we illustrate the base/derived inheritance relationship with the simple hierarchy: the derived class refers upward to its direct base class. object ^ | C There actually is a reference in the object representing the derived class C to the object representing the base class "object", which we will use in the next lecture: the arrow's direction is meaningful: the derived class refers to the base class it was derived from (not the other way around). Each class "KNOWS" its base class(es), but a class DOES not "KNOW" its derived class(es): the arrow is one way. Note that many other classes that we know inherit only from the object class (e.g., the standard date types list, set, and dict), and we shall soon see that defaultdict inherits from dict, by actually examining its code). Here is how we show the inheritance hierarchy of these classes as an N-ary tree, with object as its root. object ^ ^ ^ / | \ list set dict ^ | defaultdict If we use only single-inheritance the resulting inheritance hierarchy is an N-ary tree (a generalization of binary tree) with object at its root. Derived classes are children of their base classes. Note that the derived classes can themselves be base classes of other derived classes (e.g., dict is a class derived from object, but a base class from which defaultdict is a derived class). Eventually (tracing upward from any class towards the root of the inheritance hierarchy) following arrows from a derived class to its base class will lead upward in the inheritance tree until the root (the class "object") is reached. The situation gets a bit more complicated when multiple-inheritance is allowed (more details in the next lecture). So in this lecture we will being seeing class definitions like class Derived_Class(Base_Class): # single-inheritance and in the next lecture like class Derived_Class(Base_Class1, Base_Class2, ...): # multiple-inheritance Many languages support just single-inheritance (a derived class can have only one direct base class: Java and Smalltalk are examples of such languages) but Python (like C++) supports multiple inheritance: a derived class can have many direct base classes. Once we understand the general principle of locating attributes in a single-inheritance hierarchy, we can generalize the look-up rules for multiple-inheritance hierarchies. Finally, note an asymmetry: a derived class specifically states the name of (and refers to) its base classes, but a base class says nothing about (and doesn't refer to or know anything about) its derived classes. There is no way for a base class to know what derived classes will inherit from it: the inheritance mechanism (for locating attributes) allows derived classes to access attributes in their base classes, but base classes cannot access attributes in their derived classes. ------------------------------------------------------------------------------ The Fundamental Equation of Object-Oriented Programming (generalized, but in this lecture only for single-Inheritance) At present we know that instance objects refer to the class objects they are constructed from, and we can use the type function on any instance to return a reference to the class object that it was constructed from. When we look up an attribute of an instance, Python first looks for the attribute in the namespace of the instance object itself, but it if doesn't find the attribute there, it looks for the attribute in the class the object was constructed from. We captured this rule in the Fundamental Equation of Object-Oriented Programming (shown again below) in its current form, but soon expanded for inheritance. So, if "m" is a method (or "a" is any attribute), we have seen that if the method/attribute IS NOT stored directly in o's namespace/__dict__, accessing it is equivalent to trying to access o.m(...) -> type(o).m(o,...) method attributes (static methods omit o argument) o.a -> type(o).a data attributes When locating a method attribute for instance o, if it is not found in o itself (and it typically isn't) Python uses the attribute in the type(o) class: the class o was constructed from, calling that method with o as the first argument (which is why non-static methods in classes all are defined to have their first parameter, typically named self; FEOOP for static methods omits o as this first argument when it translates the call). By this mechanism, an object doesn't need to store in its own namespace all the methods that operate on it. Those methods can be stored (and shared) in the class object for each instance object, and located there as needed by FEOOP. This mechanism works for non-method attributes too: if any data attribute is not found in the instance object's namespace (although state is typically found in the instance object), Python will try to locate it by using the namespace of the class the instance object was constructed from. So, it is possible for objects to share data in its class's namespaces, just as methods are shared. We have seen that a useful example of data sharing might occur in a Person class. Suppose that we need to store how many fingers every Person (object constructed from that class) has. We could store this information by adding an attribute to every Person object (e.g., person1.fingers = 10). But the vast majority of people have 10 fingers, so we could store Person.fingers = 10 (an attribute in the Person class). Now if we don't store the fingers attribute in the person1 object, it will be found in the Person class object, with value 10. If person1 has fewer than 10 fingers, we can store person1.fingers = 9; now, for person1, the fingers attribute will be found in the object itself, not in its class. Using this approach, we can save space by storing the fingers attribute only for people not having 10 fingers (likely to be a small percentage of all the people objects), although it takes two steps to find it: looking in the object's namespace (and failing) and then looking in the class's namespace. If the fingers attribute is found in the person1 object, then the Person class object is not searched. What would happen if we somewhere executed Person.fingers += 1? Any data that should be "shared" by all the objects in a class should be stored in the namespace of their class. We now generalize this look up mechanism for single-inheritance hierarchies: 1) Python first tries to find the attribute in the instance object. 2) If Python fails, it next tries to find the attribute in the class object from which the instance was constructed (the type function provides this information: we can compute the class from which any objects is constructed). 3) If Python fails, it tries to find the attribute in the base class of the class from which the instance was constructed; 4) If Python fails, it next tries to find it in the base class of that class, ... and continues until it reaches the object class at the root of the inheritance hierarchy. 5) If Python fails to find the attribute in the object class, it calls __getattr__ in the original class of the object (and if it is not there, it repeats searching for that method in the hierarchy). The object class at the root of the hierarchy defines __getattr__ to raise an AttributeError exception. Pictorally, where Python looks for an o.attribute object <-- looks here last (root of hierarchy) ^ | ... ^ | class <-- looks here 3rd ^ | Look here 1st --> o --> class <-- looks here 2nd (instance) (o constructed from) ^ | class <-- doesn't look here ^ | class <-- doesn't look here Note that classes derived from the class o is constructed from (shown below o's class) are not searched; searching is only sideways from o to the class it was constructed from and then upward towards the "object" class ------------------------------------------------------------------------------ A very simple but representative example of Inheritance Examine the following two simple classes (which are in the counters.py module that you can download with this lecture). We can examine and use the Counter class by itself (it is a fully working class), but Counter also acts as the base class of the derived Modular_Counter class. Together, most interesting issues concerning single inheritance can be illustrated simply in these two classes, in which Counter is the base class and Modular_Counter is the derived class. class Counter: # implicitly use object as its base class hierarchy_depth = 1 # object is at depth 0, Counter is 1 beneath it counter_base = 0 # track how often Counter.__init__ is called def __init__(self,init_value=0): assert init_value >= 0,\ 'Counter.__init__ init_value('+str(init_value)+') < 0' self._value = init_value Counter.counter_base += 1 # Increment class variable on __init__ call def __str__(self): # str of Counter is str of its _value return str(self._value) def reset(self): # reset of Counter assigns _value = 0 self._value = 0 def inc(self): # inc(rement) of Counter increments _value self._value += 1 def value_of(self): # value_of Counter is _value (an int) return self._value Here __str__ and value_of are queries; reset and inc are commands. As we will see below, we supply such methods so that we never need to directly refer to the data attributes in a Counter object. Instead we refer to these Counter methods. Note in the Modular_Counter class we never directly refer to these data attributes. class Modular_Counter(Counter): # explicitly use Counter as its base class hierarchy_depth = Counter.hierarchy_depth + 1 # 1 more than Counter's depth counter_derived = 0 # how many times Modular_Counter.__init__ called def __init__(self,init_value,modulus): assert modulus >= 1,\ 'Modular_Counter.__init__ modulus('+str(init_value)+') < 1' assert 0 <= init_value < modulus,\ 'Modular_Counter.__init__ init_value('+str(init_value)+') not in [0,'+str(modulus)+')' Counter.__init__(self,init_value) self._modulus = modulus Modular_Counter.counter_derived += 1 def __str__(self): return Counter.__str__(self)+' mod '+str(self._modulus) # Note, calling self.value_of() and self.reset() is equivalent to (and # preferred to) calling Counter.value_of(self) and Counter.reset(self) # But it is critical that Counter.inc(self) is called that way, because # calling self.inc() would be an infinitely recursive call to inc. def inc(self): if self.value_of() == self._modulus - 1: self.reset() else: Counter.inc(self) def modulus_of(self): return self._modulus The main script at the bottom of the module creates Counter and Modular_Counter objects and then allows the user to type in commands using the names c and mc (including defining new/more names). You can put more code down there using c and mc, or enter commands that are executed by calling the exec function. if __name__ == '__main__': import prompt c = Counter(0) mc = Modular_Counter(0,3) while True: try: exec(prompt.for_string('Command')) except Exception as report: import traceback traceback.print_exc() ---------- The Counter base class First we will discuss the Counter class, and then discuss the Modular_Counter class, which is derived from the Count base class. The Counter class defines two class attributes that are shared by all Counter objects: hierarchy_depth, which doesn't change, and counter_base which is incremented each time __init__ is called. The Counter class also stores the class method names __init__, __str__, reset, inc, and value_of. (1) hierarchy_depth is an int representing the depth of the Counter class in the N-ary inheritance tree: it is 1, because it is below the root of the tree, the object class (which has depth 0). (2) counter_base is an int that counts how many times __init__ is called in the Counter class. Counter's __init__ is called when we construct Counter objects, but we also see that it is called when we construct Modular_Counter objects (see definition below, which calls Counter.__init__ explicitly): it is common for the __init__ method in the derived class to call the __init__ method in its base class (base classes -plural- for multiple inheritance). (3) __init__ initalizes each Counter object to have one piece of state: an int that is always (so it must start) >= 0 representing the counter's value. It verifies this property of init_value first, and then increments counter_base. So, understand the following difference: the Counter class object stores the hierarchy_depth and counter_base attributes (as well as all the other method attributes). Each object constructed from the Counter class stores its own _value attribute (and shares the attributes in the Counter class through use of the Fundamental Equation of Object-Oriented Programming). (4) __str__ is a query that returns a string representing the Counter object's _value attribute. (5) reset is a command that resets the Counter object's _value attribute to 0. (6) inc is a command that increases the Counter object's _value attribute by 1. (7) value_of is a query that returns the _value attribute of the Counter object. The difference between (4) and (7) is that (4) returns a str -typically for printing- while (7) returns an int -typically for using with arithmetic and relational operators. Using the code in main, we can construct and experiment with objects from the Counter class: looking up state/calling methods on c (an object already constructed in the code) or constructing more Counter objects and doing the same on them. For example, try c = Counter(0) # OK type Counter (------------------) +---+ | hierarchy_depth | int | --+--------->| +------+ | (---) +---+ | | ---+---------+---->| 1 | | +------+ | (---) | | | counter_base | int | +------+ | (---) | | ---+---------+---->| 0 | | +------+ | (---) | | | | | __init__ | function | +------+ | (---) | | ---+---------+---->| | | +------+ | (---) | | | ... all the | | other methods | | | (------------------) Counter c (----------) +---+ | _value | int | --+--------->| +------+ | (---) +---+ | | ---+-+---->| 0 | | +------+ | (---) (----------) print(c.hiearchy_depth) # prints 1 (because c = ...; FEOOP for data attribute) print(c.counter_base) # prints 1 (because c = ...; FEOOP for data attribute) c.inc() # OK; uses FEOOP to call method attribute c.inc() # OK; uses FEOOP to call method attribute c.inc() # OK; uses FEOOP to call method attribute print(c) # prints 3 via calling str/__str__ on c, using FEOOP print(c.value_of()) # prints 3 via calling str(c.value_of()), using FEOOP ---------- The Modular_Counter derived class Now we will discuss the Modular_Counter class, a specialization of the Counter class: a Modular_Counter IS-A special kind of Counter. Generally, class B should be derived from class A if B IS-A special kind of A (e.g., a defaultdict IS-A special kind of dict). We will classify every Class attribute usable in the derived class as (a) a new attribute defined in the derived class: not an inherited attribute (b) an inherited attribute: not defined/overridden in the derived class (c) an inherited attribute: (re)defined in the class OVERRIDING an inherited one These last two options rely on an understanding of what it means for a derived class to override an inherited attribute. A derived class overrides an inherited attribute if it defines an attribute (using the same name) as an attribute it inherits: one already defined in its base class, the base class of its base class, etc. all the way back to the object class at the root of the inheritance hierarchy (which defines lots of double-underscore methods). When we describe the Modular_Counter class below, we will discuss all of its defined attributes in these terms. Generally a modular counter IS-A special kind of counter. In fact, derived classes are typically specializations of base classes: for inheritance to be natural, all operations on the base class must MAKE SENSE on the derived class too, although the derived class may change the MEANING OF (override) some of the inherited attributes; of course, it can also define new attributes. So a base class and its derived class are in an IS-A relationship. The derived class IS-A special kind of base class: The Modular_Counter is a special kind of Counter, where all the operations on Counter objects also make sense on Modular_Counter counter objects: some are done the same (inherited, like reset) and some are done differently (overridden, inc). Modular counters can store values between 0 up to but not including the modulus (so the biggest a value can get is modulus-1): think of a counter for strikes in baseball as a modulus 3 counter. A batter goes from 0 strikes to 1 strike to 2 strikes to being out, with the next batter starting again back at 0 strikes. Or the digits in a car odometer go from 0 to 9 and then back to 0 again (with a carry-over to the next digit) so each digit is a modulus 10 counter. The Modular_Counter class defines two attributes: hierarchy_depth is (c), which doesn't change; counter_derived is (a) which is incremented in __init__); the class method named __init__ is (c), __str__ is (c), inc is (c), and modulus_of is (a). It also inherits and does not override the attributes counter_base, reset, and value_of (all are b). (1) hierarchy_depth is an int representing the depth of the Modular_Counter class in the N-ary inheritance tree: it is 2, because it is derived from the Counter class, whose depth is 1. (2) counter_derived is an int that counts how many times __init__ in the Modular_Counter class is called (in this two-class hierarchy, it is called only when we construct Modular_Counter objects). (3) __init__ initalizes each Modular_Counter object to have two pieces of state: first, an int that remains unchanged and must be >= 1 representing the counter's modulus; second, an int that is always (so it must start) >= 0 and stictly < the modulus, representing the module counter's value. The __init__ verifies these properties of modulus and init_value first, and increments counter_derived. Note that THE _value ATTRIBUTE IS NOT DIRECTLY DEFINED IN THIS __init__ METHOD, but instead it is defined in the instance object when __init__ calls Counter.__init__ on this object (which adds to its attributes). The self object is passed along to Counter's __init__ where it is updated (by adding the _value attribute to its namespace). Overriding methods often call the same named method in their base class (the overridden one) to help get their job done. (4) __str__ is a query that returns a string representing the Modular_Counter object's _value attribute, by calling Counter.__str__(self) concatenated with the word ' mod ' and the string equivalent of the modulus attribute. Overriding methods often call the method they override to help them get their job done. (5) inc is a command that resets the Modular_Counter object's _value attribute to 0 if the current value is one less than the modulus, otherwise it increments the Modular_Counter's _value attribute by 1, by calling Counter.inc(self). In this way the _value attribute is still always >= 0 and < the _modulus attribute. Overriding methods often call the method they override to help them get their job done. (6) modulus_of is a query that returns the _modulus attribute of the Modular_Counter object. (7) The methods reset and value_of are inherited directly from the Counter class. When called on a Modular_Counter object, these attributes are not found in the Modular_Counter class, but are found in the Counter class (using our revised look-up rules explained above). Generally in programming, the methods in a class should refer to a state attribute (e.g., a.o) ONLY IF IT IS DEFINED IN THAT SAME CLASS. That is why in Modular_Counter's __init__, _value is not defined directly, but is instead defined/initialized by calling Counter's __init__; it is also why Modular_Counter's inc method calls the inherited methods reset, inc, and value_of, instead of directly manipulating its _value attribute. In this approach, we consider state names to be private and should be used only within the class that is responsible for defining and manipulating those attribute. It is that class that ensures the integrity/invariant of that attribute. In contrast, generally method names are considered public and usable with any object (unless they start with _ or __). Because we can refer to all the state and behavior attributes in Python, Python programmers don't always follow this rule (although we have seen how to use __setattr__ to disallow rebinding an attribute in an object). Java and C++ programmers must define each attribute as public (usable anywhere) or private (usable only within the class in which it is defined) and the computer ensures and enforces that private attributes are examined/changed only in the class that defines them (there are other, more subtle options in these languages). ---------- Constructing an Object from a Derived Class When we call Modular_Counter(0,3) -or just Modular_Counter(modulus=3)- Python constructs an instance object whose class/type is Modular_Counter; this instance object starts with an empty namespace (dictionary). It checks the 2 assertions about the arguments: both are True, so Python calls Counter.__init__(init_value). Counter.__init__ checks its assertion: it is True, so it adds the binding _value to the namespace of the constructed Modular_Counter object passed to self and binds it to init_value (here 0); it also increments Counter.counter_base: the attribute counter_base defined in the Counter class object Then, returning to Modular_Counter.__init__ Python adds the binding _modulus to the namespace of the constructed Modular_Counter object (so this object now has two attributes in its dictionary: _value and _modulus) and binds it to the modulus (here 3); it also increments Modular_Counter.counter_derived: the attribute counter_derived defined in the Modular_Counter class object. So, at this point the newly constructed Modular_Counter object stores two data attributes (_value -storing 0- and _modulus -storing 3), the Counter class object itself stores two data attributes (hierarchy_depth (storing 1) and counter_base (storing 1), along with all its method attributes), and the Modular_Counter class object stores two data attributes (hierarchy_depth (storing 2) and counter_derived (storing 1), along with all its method attributes). Note that executing x = Modular_Counter(0,3) means type(x) is Modular_Counter; while executing x = Counter(0) means type(x) is Counter. The type function always returns the class object from which any instance object is constructed. ---------- Avoiding Infinite Recursion in Methods called defined in Derived Classes Note carefully the __str__ method defined in the Modular_Counter class overrides the __str__ method this class inherits from the Counter class. def __str__(self): return Counter.__str__(self)+' mod '+str(self._modulus) Suppose we define x = Modular_Counter(0,3). Then the Fundamental Equation of Object-Oriented Programming tells us str(x) is executed as x.str() which is executed as type(x).__str__(x) or Modular_Counter.__str__(x). Which executes Counter.__str__(x)+... which returns '0'+.... Now, what would happen if we instead defined def __str__(self): # WRONG return str(self)+' mod '+str(self._modulus) # WRONG Here, str(self) is still executed as self.__str__() which is still executed as type(self).__str__(self) or Modular_Counter.__str__(self). So the body of this function calls str(self) which is executed again as type(self).__str__(self) or Modular_Counter.__str__(self) so we have created infinite recursion with this change. So generally, in a method that OVERRIDES an INHERITED method, if we want to call the INHERITED method inside it, we must preface it with the class in which the inherited method is to be called. This was also done in the call to inc in the Modular_Counter's inc method. Notice that it was not done for the call to reset, which appears as just self.reset(); the reason is that the reset method is inherited from the Counter class but is NOT OVERRIDDEN in Modular_Counter, so when making a call to reset on a Modular_Counter object, Python finds that the reset attribute is NOT in the Modular_Counter class, but then in the next step it looks and does find the reset attribute in the Counter class, so it calls that method. This is all by the augmented Fundamental Equation of Object- Oriented Programming rule. -----super start In Python 3, we can also call super() to call a method in the base class implicitly: def __str__(self): return super().__str__()+' mod '+str(self._modulus) Note that in this case using super automatically provides the self argument to the method call. I prefer referring to Counter.__str__ explicitly. But by using super(), if we change the base class of Modular_Counter (say to New_Counter), the call to super().__str__ would still be correct: we wouldn't have to also change Counter.__str__ to the New_Counter.__str__. I still prefer writing in the specific base class for beginners. ----super end Let us predict (and verify) what is printed (and why) in the following code. c = Counter(0) # OK m = Modular_Counter(0,3) # OK Counter c (----------) +---+ | _value | int | --+--------->| +------+ | (---) +---+ | | ---+-+---->| 0 | | +------+ | (---) (----------) Modular_Counter m (----------) +---+ | _value | int The Modular_Counter object has two | --+--------->| +------+ | (---) attributes: _value defined in +---+ | | ---+-+---->| 0 | Counter.__init__ and _modulus defined | +------+ | (---) in Modular_Counter.__init__, which | | calls Counter.__init__ | _modulus | int | +------+ | (---) | | ---+-+---->| 3 | | +------+ | (---) (----------) print(c.hierarchy_depth) # 1 - class attribute in Counter print(c.counter_base) # 2 - ditto - (from both c = ... and m = ...) print(c.counter_derived) # AttributeError exception print(m.hierarchy_depth) # 2 - class attribute in Modular_Counter print(m.counter_base) # 2 - ... in Counter- (same as c.counter_base) print(m.counter_derived) # 1 - ... in Modular_Counter- (from only m = ...) c.inc() # OK - method: class attribute in Counter c.inc() # OK - method: class attribute in Counter c.inc() # OK - method: class attribute in Counter print(c.value_of()) # 3 - method: class attribute in Counter print(c.modulus_of()) # AttributeError exception m.inc() # OK - method: class attribute Modular_Counter m.inc() # OK - method: class attribute Modular_Counter m.inc() # OK - method: class attribute Modular_Counter print(m.value_of()) # 0 - method: class attribute in Counter print(m.modulus_of()) # 3 - method: class attribute Modular_Counter You should completely understand this example for the upcoming quiz and final exam. ------------------------------------------------------------------------------ Inheritance Design Rules In languages like Java/C++, if a class adds a data attribute (state) to an object (mostly in __init__) then only THAT class is allowed to access that state directly as an attribute. That is, if x = C() and C's __init__ contains self.a = ... then if any other class contains contains code that attempts to refer to x.a or rebind x.a (e.g., x.a = ....) it will raise an exception. Python does NOT have this restriction, but it is still considered bad design form to write such accesses/rebinding in any other class, but the one that defines the attribute. To solve this problem, each class should define methods to access/set all the attributes it defines. That is why in there are reset/inc/value_of methods in the Counter class: so the Modular_Counter class can examine/update the _value attribute defined in Counter, without explicitly writing self._value in Modular_Counter. So, 1) If a class adds an attribute to an object (e.g., in __init__) then methods defined in only that class should access the attribute directly by name. 2) If other classes (including derived/subclasses) need to access/update the information stored in that attribute, then the class defining the attribute should also define METHODS that do these jobs, which the other class should call.. We can summarize these rules as, "Python allows any code to access all the attributes in an object by writing self.attribute. But we should USE SELF CONTROL". Here "self control" means don't access the attributes defined in other classes by writing self.attribute; instead call the appropriate method to access the attribute (which may be by writing self.method() or Class.method()). Some languages use the terms accessors/getters for methods that return the value of such attributes, and mutators/setters for methods that rebind an attribute to refer to a different value. The Counter class does not allow us to arbitrarily set the _value attribute: we have methods only to set it to 0 or increment it by 1. ------------------------------------------------------------------------------ 1) Describe what happen using the following definitions and code class B: def __init__(self): self.a = 1 class D: def __init__(self): self.a = 2 b = B() d = D() print(b.a) print(d.a) 2) Suppose that we define the __str__ method in the Counter class to return a string representing value as a roman numeral; what will the statements print(Modular_Counter(2,3)) print? 3) Suppose that we define the print_it method in the Counter class as follows: def print_it(self): print(self.__str__()) What is the result of defining c = Counter(0) and c.print_it(); same for mc = Modular__Counter(0,3) and mc.print_it()? Does defining print_it as follows change the result printed? Explain how each determines what value to print and what value it prints. def print_it(self): print(str(self)) 4) Define __repr__ functions in Count and Modular_Counter classes. 5) Suppose that we defined l = [counter(0), Modular_Counter(0,3), Modular_Counter(0,3), Counter(0)] What would be printed if we executed the code for i in range(3): for c in l: c.inc() print(l) 6) Examine the the following definitions and executable code. class Base: def m1(self): print('m1 in Base') def m2(self): print('m2 in Base') self.m1() class Derived(Base): def m1(self): print('m1 in Derived') def m2(self): print('m2 in Derived') Base.m2(self) # or the call super().m2() o = Derived() o.m2() a) Which of the following three lines does it print? m2 in Derived m2 in Base m1 in Base or m2 in Derived m2 in Base m1 in Derived b) What changes could be make to these classes to get it to print the other three lines?