Exercise section 1

class JazzRecord:
    
    def __init__(self, player, album, year):
        self.player = player
        self.album = album
        self.year = year
        
    def as_string(self):
        return "{} (by {}, {})".format(self.album, self.player, self.year)
    
record = JazzRecord("John Coltrane", "Giant Steps", 1957)

print(record.as_string())
Giant Steps (by John Coltrane, 1957)

Exercise section 2

import math

# we have to repeat some class declarations here 
# so that our solution works in this notebook:

class Point2D:
    
    def __init__(self, x0, y0):
        self.x = x0     # set attribute x
        self.y = y0     # set attribute y
        
    def distance_to_origin(self):
        """method which computes length of Vector2D"""
        return math.hypot(self.x, self.y)
    
    def as_string(self):
        return "<Point2D x={}, y={}>".format(self.x, self.y)
    

class Vector2D(Point2D):
        
    def add(self, other):
        """ is called when you execute self + other """
        return Vector2D(self.x + other.x, self.y + other.y)
    
    def length(self):
        return self.distance_to_origin()
    
    def as_string(self):
        return "<Vector2D x={}, y={}>".format(self.x, self.y)


class FancyVector2D(Vector2D):
    
    def __init__(self, x, y, name):
        
        # call method of same name in base class
        super().__init__(x, y)
        self.name = name

    def __len__(self):
        # implements len(..) for a Point2D Object
        return 2
    
    def __str__(self):
        # to string conversion, when str(.) is called
        # or implicitly within print:
        return "<FancyVector2D x={}, y={} name='{}'>".format(self.x, self.y, self.name)
    
    def __add__(self, other):
        # implements "self + other"
        assert isinstance(other, FancyVector2D)
        new_name = "{} + {}".format(self.name, other.name)
        return FancyVector2D(self.x + other.x, self.y + other.y, new_name)
    
    def __getitem__(self, index):
        # square bracket access
        if 0 <= index < 2:
            return (self.x, self.y)[index]
        raise IndexError()
        
    def __eq__(self, other):
        # implements "self == other"
        if type(self) != type(other):
            return False
        return self.x == other.x and self.y == other.y
    
    
    # SOLUTIONS FROM THE EXERCISE:
    
    def scale(self, factor):
        self.x *= factor
        self.y *= factor
        
    def __mul__(self, other):
        assert isinstance(other, Vector2D)  # can also be vector2d here !
        return self.x * other.x + self.y * other.y
        
        
f = FancyVector2D(2, 1, "v1")
f.scale(2)
print(f)

v = Vector2D(1, 2)
print(f * v)
<FancyVector2D x=4, y=2 name='v1'>
8

This solution implements a bit more than the exercise asked for:

class ComplexNumber(Vector2D):
    
    def __str__(self):
        if self.y == 0:
            return str(self.x)
        elif self.x == 0:
            return "{} i".format(self.y)
        elif self.y > 0:
            return "{} + {} i".format(self.x, self.y)
        else:
            return "{} - {} i".format(self.x, -self.y)
        
    def __mul__(self, other):
        re = self.x * other.x - self.y * other.y
        im = self.x * other.y + self.y * other.x
        return ComplexNumber(re, im)
    
    # EVERYTHING WHAT FOLLOWS NOW WAS NOT PART OF THE
    # EXERCISE !
    
    # used to print internals using repr() function    
    # we use the same implementation as for __str__:
    __repr__ = __str__
            
    
    def __neg__(self):
        "implements unary minus"
        return ComplexNumber(-self.x, -self.y)
    
    def __abs__(self):
        "called by abs(..) function"
        return self.length()
    
    def __iadd__(self, other):
        "implementx self += other"
        self.x += other.x
        self.y += other.y
        return self
        
    def __float__(self):
        "implements type conversion to float."
        assert self.y == 0
        return float(self.x)  # could be int
 
    def __hash__(self):
        """computes hash value, required if you want to use complex
        numbers as keys of a dictionary, you als must implent
        __eq__ to complete this"""
        return hash((self.x, self.y))  # use builtin hash function
    
    def __eq__(self, other):
        if type(self) != type(other):
            return False
        return self.x == other.x and self.y == other.y
    
    def __getattr__(self, name):
        "is called when the user uses unknown attributes"
        if name == "real":
            return self.x
        if name == "imag":
            return self.y
        raise AttributeError("instances of ComplexNumber have no attribute '{}'".format(name))
        
    @property
    def conjugate(self):
        """properties are computed attributes, so the client writes
        c.conjugate and not c.conjugate()
        
        we also could have used properites for implementin additional
        real and imag attributes as we did it in __getattr__.
        """
        return ComplexNumber(self.x, -self.y)

        
c1 = ComplexNumber(1, 1)
c2 = ComplexNumber(1, -1)
c3 = ComplexNumber(2, 0)

print("check to string conversion")
print("c1 is", c1)
print("c2 is", c2)
print("c3 is", c3)

print()
print("check multiplication")

print("c1 * c1 is", c1 * c1)
print("c1 * c2 is", c1 * c2)
print("c1 * c3 is", c1 * c3)

print()

print("-c1 is", -c1)
print("abs(c1) is", abs(c1))

print()
c1 += c2
print("c1 += c2 updated c1 to", c1)

print("c3 has no imag value, so conversion to float is", float(c3))

print()
print("dictionary with complex numbers as keys:")
dd = {c3: 1, c2: 2}
print(dd)

# this will also call __repr__ for the keys:
print("dd[c3] is", dd[c3])

print()
print("check if attribute lookup for real and imag works:")
print("c3.real is", c3.real)
print("c3.imag is", c3.imag)

print()
print("c1.conjugate is", c1.conjugate)
check to string conversion
c1 is 1 + 1 i
c2 is 1 - 1 i
c3 is 2

check multiplication
c1 * c1 is 2 i
c1 * c2 is 2
c1 * c3 is 2 + 2 i

-c1 is -1 - 1 i
abs(c1) is 1.4142135623730951

c1 += c2 updated c1 to 2
c3 has no imag value, so conversion to float is 2.0

dictionary with complex numbers as keys:
{2: 1, 1 - 1 i: 2}
dd[c3] is 1

check if attribute lookup for real and imag works:
c3.real is 2
c3.imag is 0

c1.conjugate is 2

Optional exercise

The main issue is that we have two not fully compatible inheritance hierarchies for the employees, the suggested position-based:

- Employee
    - Manager
        - Boss
        - LowerManager
    - Subordinate
        - Staff
        - Student

and the alternative indirectly implied supervision-based hierarchy:

- Employee
    - UnsupervisedEmployee
        - Boss
    - SupervisedEmployee
        - LowerManager
        - Staff
        - Student

This can be solved in at least two reasonable ways: by applying the mixin pattern, or a variant of so called Null object pattern.

Further technical issues to consider:

  • How to implement access to employees list in the Department such that supervision checks are enforced? E.g. department.employees.append(employee) shouldn't be possible. What about the read access - do we need to copy the list for the same reasons?
  • Optionally, equality checks for comparison of employees, in particular, for the check of existence in the department.

Let's start w/ the Department, which is mostly independent of the Employees hierarchy:

class Department:

    def __init__(self, name, employees=None):
        self.name = name
        # list is private; write via `.add_employee()`, read via `.get_employees()`
        self._employees = [] if employees is None else employees

    def add_employee(self, employee):
        # Note: because Python is dynamically typed, lack of the Employee declaration 
        # won't trigger any errors until this bit of code actually runs
        assert isinstance(employee, Employee)

        if employee.is_supervised() and employee.supervisor not in self._employees:
            raise ValueError(
                "Can't add {employee}! His supervisor-{supervisor}-is not employed "
                "in {department}.".format(
                    employee=employee.full_name(),
                    supervisor=employee.supervisor.full_name(),
                    department=self,
                )
            )
        self._employees.append(employee)
        # Employees are supposed to know to which department they belong
        employee.department = self

    def get_employees(self):
        # Using generator pushed responsibility for list, if needed, on the caller
        for employee in self._employees:
            yield employee

    def __str__(self):
        return "{} department".format(self.name)

Let's test the department class w/o employees:

department = Department("Silly Walks")

print(department, "employees are:")
for employee in department.get_employees():
    print("*", employee)
print()
Silly Walks department employees are:

Let's implement first Employees using a simplified variant of the Null object pattern, where as a NoSupervisor instance we simply take None value.

from abc import ABC, abstractmethod


class Employee(ABC):

    def __init__(self, name, surname, supervisor):
        self.name = name
        self.surname = surname
        self._validate_supervisor(supervisor)
        self.supervisor = supervisor
        self.department = None

    def _validate_supervisor(self, supervisor):
        if self.is_supervised() and not supervisor.is_manager():
            raise ValueError(
                "Supervisor of {employee} has to be a Manager instance, whereas "
                "{supervisor} is not.".format(
                    employee=self.full_name(),
                    supervisor=supervisor,
                )
            )

    def is_supervised(self):
        # By default everyone is supervised, but this behaviour can be overridden
        return True

    def has_department(self):
        return self.department is not None

    @abstractmethod
    def is_manager(self):
        pass

    @abstractmethod
    def position(self):
        pass

    def full_name(self):
        return "{surname}, {name}".format(name=self.name, surname=self.surname)

    def __str__(self):
        ret = self.full_name()
        if self.has_department():
            ret += ", {position} in {department}".format(
                position=self.position(),
                department=self.department,
            )
        if self.is_supervised():
            ret += ", supervised by {supervisor}".format(
                supervisor=self.supervisor.full_name()
            )
        return ret


class Manager(Employee):

    def is_manager(self):
        return True


class Boss(Manager):

    def __init__(self, name, surname):
        super().__init__(name, surname, None)

    def is_supervised(self):
        return False

    def position(self):
        return "boss"


class LowerManager(Manager):

    def position(self):
        return "lower management member"


class Subordinate(Employee):

    def is_manager(self):
        return False


class Staff(Subordinate):

    def position(self):
        return "staff member"


class Student(Subordinate):

    def position(self):
        return "student"

Let's run some tests w/ employees:

department = Department("Silly Walks")

boss = Boss("Brian", "Cohen")
print(boss)
department.add_employee(boss)
print(boss)

try:
    LowerManager("Prophet", "Blood & Thunder")
except TypeError as e:
    print("Expected ERROR:", e)

try:
    LowerManager("Prophet", "Blood & Thunder", Student("Joe", "Doe", boss))
except ValueError as e:
    print("Expected ERROR:", e)

manager_1 = LowerManager("Dirk", "Deadly", boss)
manager_2 = LowerManager("Prophet", "Blood & Thunder", manager_1)
subordinates = [
    Staff("Big", "Nose", manager_1),
    Staff("Guard", "Giggling", manager_1),
    Staff("Helper", "Stoners", manager_1),
    Student("Follower", "Shoe", manager_2),
    Student("Prophet", "False", manager_2),
]
try:
    department.add_employee(manager_2)
except ValueError as e:
    print("Expected ERROR:", e)

department.add_employee(manager_1)
department.add_employee(manager_2)
for subordinate in subordinates:
    department.add_employee(subordinate)

print(department, "employees are:")
for employee in department.get_employees():
    print("*", employee)
print()
Cohen, Brian
Cohen, Brian, boss in Silly Walks department
Expected ERROR: __init__() missing 1 required positional argument: 'supervisor'
Expected ERROR: Supervisor of Blood & Thunder, Prophet has to be a Manager instance, whereas Doe, Joe, supervised by Cohen, Brian is not.
Expected ERROR: Can't add Blood & Thunder, Prophet! His supervisor-Deadly, Dirk-is not employed in Silly Walks department.
Silly Walks department employees are:
* Cohen, Brian, boss in Silly Walks department
* Deadly, Dirk, lower management member in Silly Walks department, supervised by Cohen, Brian
* Blood & Thunder, Prophet, lower management member in Silly Walks department, supervised by Deadly, Dirk
* Nose, Big, staff member in Silly Walks department, supervised by Deadly, Dirk
* Giggling, Guard, staff member in Silly Walks department, supervised by Deadly, Dirk
* Stoners, Helper, staff member in Silly Walks department, supervised by Deadly, Dirk
* Shoe, Follower, student in Silly Walks department, supervised by Blood & Thunder, Prophet
* False, Prophet, student in Silly Walks department, supervised by Blood & Thunder, Prophet

Let's compare w/ the Mixin pattern approach. It's arguably more secure but also technically more involved (but so would be the Null object pattern solution if we would like to implement it properly, using NoSupervisor class, subclassing e.g. from the Manager class).

from abc import ABC, abstractmethod


class Employee(ABC):

    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.department = None

    @abstractmethod
    def is_supervised(self):
        pass

    def has_department(self):
        return self.department is not None

    @abstractmethod
    def is_manager(self):
        pass

    @abstractmethod
    def position(self):
        pass

    def full_name(self):
        return "{surname}, {name}".format(name=self.name, surname=self.surname)

    def __str__(self):
        ret = self.full_name()
        if self.has_department():
            ret += ", {position} in {department}".format(
                position=self.position(),
                department=self.department,
            )
        return ret


class Manager(Employee):

    def is_manager(self):
        return True


class SupervisedEmployeeMixin:
    # This mixin does not inherit from Employee, but since it's only intended use,
    # in in Employee hierarchy, we can "in advance" call here super().__init__

    def __init__(self, name, surname, supervisor):
        # unless you have a good reason, it's best to always initialise base class
        # first - can use already its methods here, like `full_name`
        super().__init__(name, surname)
        self._validate_supervisor(supervisor)
        self.supervisor = supervisor

    def _validate_supervisor(self, supervisor):
        if self.is_supervised() and not supervisor.is_manager():
            raise ValueError(
                "Supervisor of {employee} has to be a Manager instance, whereas "
                "{supervisor} is not.".format(
                    employee=self.full_name(),
                    supervisor=supervisor,
                )
            )

    def is_supervised(self):
        return True

    def __str__(self):
        return "{self_}, supervised by {supervisor}".format(
            self_=super().__str__(),
            supervisor=self.supervisor.full_name(),
        )


class UnsupervisedEmployeeMixin:

    def is_supervised(self):
        return False


class Boss(UnsupervisedEmployeeMixin, Manager):
    # Note: a side-effect of keeping Mixin clean (no inheritance) is that it has to be
    #       declared here first, because otherwise it will be last in MRO, so on
    #       `__init__` call the ABC implementation will complain about lack of
    #       implementation of an abstract `is_supervised` method, before it's found
    def position(self):
        return "boss"


class LowerManager(SupervisedEmployeeMixin, Manager):

    def position(self):
        return "lower management member"


class Subordinate(SupervisedEmployeeMixin, Employee):

    def is_manager(self):
        return False


class Staff(Subordinate):

    def position(self):
        return "staff member"


class Student(Subordinate):

    def position(self):
        return "student"

We've only changed internal implementation, not the API of our object. We expect identical results for the same tests we run previously:

department = Department("Silly Walks")

boss = Boss("Brian", "Cohen")
print(boss)
department.add_employee(boss)
print(boss)

try:
    LowerManager("Prophet", "Blood & Thunder")
except TypeError as e:
    print("Expected ERROR:", e)

try:
    LowerManager("Prophet", "Blood & Thunder", Student("Joe", "Doe", boss))
except ValueError as e:
    print("Expected ERROR:", e)

manager_1 = LowerManager("Dirk", "Deadly", boss)
manager_2 = LowerManager("Prophet", "Blood & Thunder", manager_1)
subordinates = [
    Staff("Big", "Nose", manager_1),
    Staff("Guard", "Giggling", manager_1),
    Staff("Helper", "Stoners", manager_1),
    Student("Follower", "Shoe", manager_2),
    Student("Prophet", "False", manager_2),
]
try:
    department.add_employee(manager_2)
except ValueError as e:
    print("Expected ERROR:", e)

department.add_employee(manager_1)
department.add_employee(manager_2)
for subordinate in subordinates:
    department.add_employee(subordinate)

print(department, "employees are:")
for employee in department.get_employees():
    print("*", employee)
print()
Cohen, Brian
Cohen, Brian, boss in Silly Walks department
Expected ERROR: __init__() missing 1 required positional argument: 'supervisor'
Expected ERROR: Supervisor of Blood & Thunder, Prophet has to be a Manager instance, whereas Doe, Joe, supervised by Cohen, Brian is not.
Expected ERROR: Can't add Blood & Thunder, Prophet! His supervisor-Deadly, Dirk-is not employed in Silly Walks department.
Silly Walks department employees are:
* Cohen, Brian, boss in Silly Walks department
* Deadly, Dirk, lower management member in Silly Walks department, supervised by Cohen, Brian
* Blood & Thunder, Prophet, lower management member in Silly Walks department, supervised by Deadly, Dirk
* Nose, Big, staff member in Silly Walks department, supervised by Deadly, Dirk
* Giggling, Guard, staff member in Silly Walks department, supervised by Deadly, Dirk
* Stoners, Helper, staff member in Silly Walks department, supervised by Deadly, Dirk
* Shoe, Follower, student in Silly Walks department, supervised by Blood & Thunder, Prophet
* False, Prophet, student in Silly Walks department, supervised by Blood & Thunder, Prophet