CIS 1051 - Temple Rome Spring 2023¶

Intro to Problem solving and¶

Programming in Python¶

LOGO

LOGO

Classes and Methods¶

Prof. Andrea Gallegati

( tuj81353@temple.edu )

What we used so far, is not really object-oriented:

without explicit relationships between programmer-defined types and functions operating on them.

To make these relationships explicit, let's transform those functions into methods.

Object-Oriented Features¶

Python provides features to support object-oriented programming, with these defining characteristics:

  • Class and method definitions.
  • Most of the computation are operations on objects.
  • Objects represent things in the real world.
  • Methods represent the ways these things interact.

Time class is the way people record the time of day.

The defined functions are kinds of things people do with times.

Similarly, Point and Rectangle are well known geometrical concepts!

The object-oriented programming features that Python supports are not strictly necessary:

  • Most just provide an alternative syntax.
  • Sometimes more concise.
  • More accurate structure of the program.

No obvious connections between Time class and the functions that followed, but every function takes Time object as an argument!

This is the motivation for methods:

functions associated with a particular class.

There are many methods for:

  • strings
  • lists
  • dictionaries
  • tuples

... why not for programmer-defined types too?

Methods are semantically the same as functions, with two syntactic differences:

  • Defined inside a class definition body (explicit relationship).
  • Invoking methods is different from calling functions.

Printing Objects¶

In [2]:
class Time:
    """Represents the time of day."""

def print_time(time):
    print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))

this print_time function need a Time argument:

In [44]:
start = Time()
start.hour = 9
start.minute = 45
start.second = 0
print_time(start)
09:45:00

Let's make it a method, moving it's definition inside the class body (changing indentation):

In [7]:
class Time:
    def print_time(time):
        print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))

we can call in a couple ways ...

  • function syntax (less common)
In [12]:
Time.print_time(start)
09:45:00
  • method syntax (more coincise)
In [13]:
start.print_time()
09:45:00

In dot notation below

In [13]:
start.print_time()
09:45:00
  • print_time is the method name
  • start is the object the method is invoked on (aka subject)

The subject of a sentence is what the sentence is about.

... the subject of a method invocation is what the method is about!

The subject is always assigned to the first parameter.

here, start is assigned to time

In [7]:
class Time:
    def print_time(time):
        print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))

By convention, this is called self and it's more common to write:

In [14]:
class Time:
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))

The reason, is an implicit metaphor:

  • in a function call, the function is the active agent
print_time(start)

says “Hey print_time! Here’s an object for you to print.”

  • in object-oriented programming, objects are the active agents
start.print_time()

says “Hey start! Please print yourself.”

In this perspective (more polite and useful), shifting responsibility from the functions onto the objects:

  • makes it possible to write more versatile methods
  • makes it easier to maintain and reuse code.

Rewriting time_to_int as a method, we might be tempted to rewrite int_to_time as a method too!

In [77]:
def int_to_time(seconds):
    time = Time()
    minutes, time.second = divmod(seconds, 60)
    time.hour, time.minute = divmod(minutes, 60)
    return time

... but that doesn’t really make sense! There is no object to invoke it on.

Another Example¶

In [22]:
class Time:
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)

time_to_int (here a method) is a pure function and not a modifier !

In [41]:
start.print_time()
end = start.increment(1337)
end.print_time()
09:45:00
10:07:17

The subject start gets assigned to the first (self) parameter.

The 1337 argument, gets assigned to the second (seconds) parameter.

This mechanism can be confusing. Especially with errors.

For example, invoking increment with two arguments:

In [40]:
end = start.increment(1337, 460)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-40-fc6b983fb3d4> in <module>()
----> 1 end = start.increment(1337, 460)

TypeError: increment() takes 2 positional arguments but 3 were given

The error message itself is confusing!

The subject itself is an argument thus, overall, we had three arguments.

positional arguments¶

don’t have a parameter name (are not keyword arguments)

sketch(parrot, cage, dead=True)
  • parrot and cage are positional
  • dead is a keyword argument

A More Complicated Example¶

To write the is_after method is slightly more complicated because it takes two Time objects as parameters.

It is conventional to name the first one self and the second other.

In [37]:
class Time:
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

Then, invoke it on an object passing the other one as an argument:

In [43]:
end.is_after(start)
Out[43]:
True

... it almost reads like English: “end is after start?”

The __init__ method¶

is a special method whose full name (short for “initialization”) is:

  • two underscore characters
  • followed by the special method's name
  • then two more underscores

It gets invoked when an object is instantiated.

An __init__ method for the Time class might look like this:

In [45]:
class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

Usually __init__ parameters are named as the Class attributes.

For example, self.hour = hour stores the hour parameter value as the hour attribute of self.

Parameters are optional. Calling Time() without arguments, we get the default values:

In [46]:
time = Time()
time.print_time()
00:00:00

Providing one argument overrides hour:

In [47]:
time = Time (9)
time.print_time()
09:00:00

Providing two arguments override hour and minute:

In [48]:
time = Time(9, 45)
time.print_time()
09:45:00

Providing three arguments override all three default values:

The __str__ method¶

In [50]:
class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second
    def __str__(self):
        return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

This is another special method that is supposed to return a string representation of an object.

When we print an object, Python invokes the __str__ method:

In [51]:
time = Time(9, 45)
print(time)
09:45:00

When writing a new class, it's a good practice to start by writing:

  • __init__ to make it easier to instantiate objects
  • __str__ useful for debugging

Operator Overloading¶

With other special methods we can specify other operators behavior on programmer-defined types (Classes).

For example, to use the + operator on Time objects, let's define the special method __add__:

In [52]:
class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second
    def __str__(self):
        return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
    def __add__(self, other):
        seconds = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

Applying the + operator to Time objects, Python invokes __add__. Printing the result, Python invokes __str__.

In [53]:
start = Time(9, 45)
duration = Time(1, 35)
print(start + duration)
11:20:00

There's a lot happening behind the scenes!

operator overloading: changing an operator behavior to work with programmer-defined types.

For every operator there's a corresponding special method.

Type-Based Dispatch¶

So far we have added two Time objects.

What about to add an integer to a Time object?

In [55]:
class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second
    def __str__(self):
        return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
    def __add__(self, other):
        if isinstance(other, Time):
            return self.add_time(other)
        else:
            return self.increment(other)
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def add_time(self, other):
        seconds = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

This version of __add__ checks the type of other and invokes:

  • either add_time
  • or increment

This operation (aka type-based dispatch) dispatches the computation to different methods, based on the type of the arguments.

  • If other is a Time object, __add__ invokes add_time.
  • Otherwise it assumes that the parameter is a number and invokes increment.

It checks the type with the built-in function isinstance that takes:

  • a value
  • a class object

and returns True if the value is an instance of the class.

Here are examples that use the + operator with different types:

In [82]:
start = Time(9, 45)
duration = Time(1, 35)
print(start + duration)
11:20:00
In [57]:
print(start + 1337)
10:07:17

Unfortunately, this addition implementation is not commutative.

Passing the integer as the first argument

In [58]:
print(1337 + start)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-58-0f0a74261c37> in <module>()
----> 1 print(1337 + start)

TypeError: unsupported operand type(s) for +: 'int' and 'Time'

Instead of asking the Time object to add an integer, Python is asking an integer to add a Time object, and it doesn’t know how.

... but there's a clever solution for this!

With the special method __radd__ (for “right-side add”).

In [83]:
class Time:
    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second
    def __str__(self):
        return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
    def __add__(self, other):
        if isinstance(other, Time):
            return self.add_time(other)
        else:
            return self.increment(other)
    def __radd__(self, other):
        return self.__add__(other)
    def print_time(self):
        print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
    def time_to_int(time):
        minutes = time.hour * 60 + time.minute
        seconds = minutes * 60 + time.second
        return seconds
    def add_time(self, other):
        seconds = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)
    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)
    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

.. that is invoked when a Time object is on the right side of the + operator. Here’s the definition:

In [84]:
print(1337 + start)
10:07:17

Polymorphism¶

Type-based dispatch is useful when necessary, but fortunately that is not always the case!

One can avoid this with functions that work correctly for arguments with different types.

Many functions we wrote for strings also work for any other sequence types.

For example, the histogram to count the number of times each letter appears in a word:

In [86]:
def histogram(s):
    d = dict()
    for c in s:
        if c not in d:
            d[c] = 1
        else:
            d[c] = d[c]+1
    return d

... works as well for

  • lists
  • tuples
  • dictionaries
In [87]:
t = ['spam', 'egg', 'spam', 'spam', 'bacon', 'spam']
histogram(t)
Out[87]:
{'bacon': 1, 'egg': 1, 'spam': 4}

As long as s elements are hashable, so they can be used as keys in d.

A type that has a hash function (that takes any kind of value and returns an integer).

Immutable types are hashable:

  • integers
  • floats
  • strings

Mutable types (like lists and dictionaries) are not.

Dictionaries use these integers, called hash values, to store and look up key-value pairs.

Functions that work with several types are called polymorphic.

Polymorphism can facilitate code reuse.

The built-in function sum adds the elements of a sequence.

It works as long as the elements of the sequence support addition.

Since Time objects provide an add method, they work with sum!

In [88]:
t1 = Time(7, 43)
t2 = Time(7, 41)
t3 = Time(7, 37)
total = sum([t1, t2, t3])
print(total)
23:01:00

If all of the operations inside a function work with a given type, the function works with that type.

The best kind of polymorphism is the unintentional kind!

One discover that a function can be applied to a type never planned for.

Interface and Implementation¶

Among the goals of object-oriented design is to make software more maintainable:

  • keep the program working as other parts of the system change
  • modify the program to meet new requirements

Design Principle: to keep interfaces separate from implementations.

... methods should not depend on how the attributes are represented.

For example, the Time class methods

  • time_to_int
  • is_after
  • add_time

can be implemented in several ways, but the implementation details depend on how we represent time!

Replacing Time attributes with a single integer (seconds since midnight) would make some methods easier to write, but some others much harder.

Once a new class is deployed, a better implementation might be discovered.

When other components use this class, to change the interface is

  • time-consuming
  • error-prone

Better to design the interface carefully: to be able to change (later on) the implementation, without changing the interface!

Debugging¶

It is legal to add attributes to objects at any point, but it's easy to make mistakes!

For example, having objects with the same type, but different attributes.

Better to initialize them all in the __init__ method.

Not sure whether an object has a particular attribute?

... use the built-in function hasattr

In [106]:
hasattr(start, "hour")
Out[106]:
True
In [104]:
hasattr(start, "day")
Out[104]:
False

Or use the built-in function vars, to return (given any object) a dictionary mapping all the attribute names and values:

In [105]:
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
vars(p)
Out[105]:
{'hour': 9, 'minute': 45, 'second': 0}

Finally, this function might be useful for debugging

In [96]:
def print_attributes(obj):
    for attr in vars(obj):
        print(attr, getattr(obj, attr))

it traverses the vars dictionary to prints each attribute name and value.

In [97]:
print_attributes(p)
y 4
x 3

The built-in function getattr takes an object and an attribute name to return the attribute’s value.