Object Oriented Programming¶
OOP: Grouping of Values and Preservation of Invariants¶
Classes help in different ways: They can group related values, they can keep interdependent state consistent, and can they preserve data invariants (such as non-zero denominators).
# this is something you might have seen already
def add_fractions(f1, f2):
return (f1[0] * f2[1] + f2[0] * f1[1], f1[1] * f2[1])
# this is very difficult to read
# also, a much cleaner result would be (5, 4)
add_fractions((1, 2), (3, 4))
(10, 8)
Fractions¶
import functools
import math
# representational constants
# see context managers chapter for details
FRACTIONAL, DECIMAL = range(2)
# this is a module-level global variable
# see context managers chapter for details
_STRING_REPRESENTATION = FRACTIONAL
# see context managers chapter for details
class string_style:
def __init__(self, style):
self._new_style = style
self._old_style = None
def __enter__(self):
global _STRING_REPRESENTATION
self._old_style = _STRING_REPRESENTATION
_STRING_REPRESENTATION = self._new_style
return self
def __exit__(self, tp, value, traceback):
global _STRING_REPRESENTATION
_STRING_REPRESENTATION = self._old_style
# re-raise exceptions
return False
# see decorators chapter for details on total_ordering
@functools.total_ordering
class MyFraction:
# default parameters help with conversion from integers
def __init__(self, num=0, denom=1):
# prevent illegal instances from being created
# use assert denom != 0 only if you trust the user
if denom == 0:
# do not print error messages
# print("ERROR")
# always raise exceptions
raise ValueError("Denominator cannot be zero.")
self._num = num
self._denom = denom
# if you forget this, equality test will not work consistently
self._cancel()
# we will improve this using properties later on
def get_num(self):
return self._num
def get_denom(self):
return self._denom
# __eq__ is required for equality checks
# for simplicity, this requires "other" to be a MyFraction object
# Python's built-in Fraction supports comparisons with ints and floats, too
def __eq__(self, other):
# delegate to tuple equality instead of bothering with
# return self._num == other._num and ...
return (self._num, self._denom) == (other._num, other._denom)
# delegate to __eq__ for consistency
def __ne__(self, other):
return not self.__eq__(other)
# without aggressive cancelling,
# MyFraction(1, 2) is not equal to MyFraction(2, 4)
# cancelling can only be guaranteed through encapsulation
def _cancel(self):
gcd = math.gcd(self._num, self._denom)
self._num //= gcd
self._denom //= gcd
def __lt__(self, other):
return self._num * other._denom < other._num * self._denom
# <= > >= generated by functools
# do not modify self or other, always create a new object!
def __add__(self, other):
new_num = self._num * other._denom + other._num * self._denom
new_denom = self._denom * other._denom
return MyFraction(new_num, new_denom)
# subtract and unary minus -> assignment
# divide, invert -> assignment
# also implement __radd__, ... if your addition supports types other than MyFraction
# without this, MyFraction cannot be used in sets and dicts
# this only makes sense for immutable values (see example below)
def __hash__(self):
return hash((self._num, self._denom))
# the built-in print function uses "str" internally
def __str__(self):
# alternative implementations
# return str(self._num) + "/" + str(self._denom)
# return "{}/{}".format(self._num, self._denom)
# return f"{self._num}/{self._denom}"
# allow switching between fractional and decimal representation
return (
f"{self._num}/{self._denom}"
if _STRING_REPRESENTATION == FRACTIONAL
else f"{float(self)}"
)
# jupyter notebook uses "repr" for printing values
def __repr__(self):
# remember the repr contract https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__repr__
return f"MyFraction({self._num!r}, {self._denom!r})"
# this is required for
# if MyFraction(): ...
def __bool__(self):
# you can use implicit conversion
# return bool(self._num)
# but prefer explicit when possible
return self._num != 0
def __float__(self):
# remember: floats can overflow
try:
return self._num / self._denom
except OverflowError as oe:
# provide a nice error message on top of the stack trace
raise ValueError("This fraction cannot be represented as a float") from oe
@staticmethod
def from_float(flt):
num, denom = flt.as_integer_ratio()
return MyFraction(num, denom)
@staticmethod
def from_str(s):
split = s.split("/")
if len(split) == 1:
return MyFraction(int(split[0]))
elif len(split) == 2:
return MyFraction(int(split[0]), int(split[1]))
else:
raise ValueError("Illegal fraction format")
def with_num(self, new_num):
return MyFraction(new_num, self._denom)
def with_denom(self, new_denom):
return MyFraction(self._num, new_denom)
This fraction class is immutable by contract: Variables starting with _
are considered implementation details and not part of the public interface.
Python’s built-in Fraction
class (see https://docs.python.org/3/library/fractions.html) is immutable in the same way.
The built-in Fraction
class offers more functionality, such as support for adding fractions to integers and floats, as well as rounding.
The only reason we partially re-implemented the Fraction
class is to understand the underlying concepts.
Always use the Fraction class from the standard library if you need to use fractions.
https://github.com/python/cpython/blob/3.9py/Lib/fractions.py
from fractions import Fraction
f = Fraction(1, 1)
# Python fractions are mutable just like ours
f._numerator = 100
f
Fraction(100, 1)
# this is why mutable data should not be used in sets
mf = MyFraction(0, 1)
s = {mf}
mf._num = 5
# mf is now MyFraction(5, 1)
MyFraction(5, 1) in s
False
Assignment OOP1¶
Rechenoperatoren Implementieren Sie die Methoden
__sub__
,__div__
,__neg__
,invert
fürMyFraction
.Vergleichsoperatoren Implementieren Sie die fehlenden Vergleichsoperatoren für
MyFraction
.
import functools
import math
# representational constants
# see context managers chapter for details
FRACTIONAL, DECIMAL = range(2)
# this is a module-level global variable
# see context managers chapter for details
_STRING_REPRESENTATION = FRACTIONAL
# see context managers chapter for details
class string_style:
def __init__(self, style):
self._new_style = style
self._old_style = None
def __enter__(self):
global _STRING_REPRESENTATION
self._old_style = _STRING_REPRESENTATION
_STRING_REPRESENTATION = self._new_style
return self
def __exit__(self, tp, value, traceback):
global _STRING_REPRESENTATION
_STRING_REPRESENTATION = self._old_style
# re-raise exceptions
return False
# see decorators chapter for details on total_ordering
@functools.total_ordering
class MyFraction:
# default parameters help with conversion from integers
def __init__(self, num=0, denom=1):
# prevent illegal instances from being created
# use assert denom != 0 only if you trust the user
if denom == 0:
# do not print error messages
# print("ERROR")
# always raise exceptions
raise ValueError("Denominator cannot be zero.")
self._num = num
self._denom = denom
# if you forget this, equality test will not work consistently
self._cancel()
# we will improve this using properties later on
def get_num(self):
return self._num
def get_denom(self):
return self._denom
# __eq__ is required for equality checks
# for simplicity, this requires "other" to be a MyFraction object
# Python's built-in Fraction supports comparisons with ints and floats, too
def __eq__(self, other):
# delegate to tuple equality instead of bothering with
# return self._num == other._num and ...
return (self._num, self._denom) == (other._num, other._denom)
# delegate to __eq__ for consistency
def __ne__(self, other):
return not self.__eq__(other)
# without aggressive cancelling,
# MyFraction(1, 2) is not equal to MyFraction(2, 4)
# cancelling can only be guaranteed through encapsulation
def _cancel(self):
gcd = math.gcd(self._num, self._denom)
self._num //= gcd
self._denom //= gcd
def __lt__(self, other):
return self._num * other._denom < other._num * self._denom
# <= > >= generated by functools
# do not modify self or other, always create a new object!
def __add__(self, other):
new_num = self._num * other._denom + other._num * self._denom
new_denom = self._denom * other._denom
return MyFraction(new_num, new_denom)
# subtract and unary minus -> assignment
def __neg__(self):
return MyFraction(-self._num, self._denom)
def __sub__(self, other):
return self + (-other)
def __mul__(self, other):
return MyFraction(self._num * other._num, self._denom * other._denom)
# divide, invert -> assignment
def invert(self):
return MyFraction(self._denom, self._num)
def __div__(self, other):
return self * other.invert()
# also implement __radd__, ... if your addition supports types other than MyFraction
# without this, MyFraction cannot be used in sets and dicts
# this only makes sense for immutable values (see example below)
def __hash__(self):
return hash((self._num, self._denom))
# the built-in print function uses "str" internally
def __str__(self):
# alternative implementations
# return str(self._num) + "/" + str(self._denom)
# return "{}/{}".format(self._num, self._denom)
# return f"{self._num}/{self._denom}"
# allow switching between fractional and decimal representation
return (
f"{self._num}/{self._denom}"
if _STRING_REPRESENTATION == FRACTIONAL
else f"{float(self)}"
)
# jupyter notebook uses "repr" for printing values
def __repr__(self):
# remember the repr contract
return f"MyFraction({self._num!r}, {self._denom!r})"
# this is required for
# if MyFraction(): ...
def __bool__(self):
# you can use implicit conversion
# return bool(self._num)
# but prefer explicit when possible
return self._num != 0
def __float__(self):
# remember: floats can overflow
try:
return self._num / self._denom
except OverflowError as oe:
# provide a nice error message on top of the stack trace
raise ValueError("This fraction cannot be represented as a float") from oe
@staticmethod
def from_float(flt):
num, denom = flt.as_integer_ratio()
return MyFraction(num, denom)
@staticmethod
def from_str(s):
split = s.split("/")
if len(split) == 1:
return MyFraction(int(split[0]))
elif len(split) == 2:
return MyFraction(int(split[0]), int(split[1]))
else:
raise ValueError("Illegal fraction format")
def with_num(self, new_num):
return MyFraction(new_num, self._denom)
def with_denom(self, new_denom):
return MyFraction(self._num, new_denom)
Binary Min-Heap¶
See https://en.wikipedia.org/wiki/Binary_heap for details
class MinHeap:
# never use mutable default parameters such as lists
def __init__(self, iterable=None):
self._buffer = []
if iterable is None:
return
for elem in iterable:
self.add(elem)
# def _sift_up(self, idx):
# if idx == 0:
# return
# parent = (idx - 1) // 2
# if self._buffer[parent] < self._buffer[idx]:
# return
# self._swap(idx, parent)
# self._sift_up(parent)
# _sift_up und _sift_down are implementation details
# and not part of the public interface
# this is why we prefix them with "_"
def _sift_up(self, idx):
# prefer this over a recursive solution because of stack overflows
while idx > 0:
parent = (idx - 1) // 2
# this requires only __lt__ to be defined!
if self._buffer[parent] < self._buffer[idx]:
break
self._swap(idx, parent)
idx = parent
# def _sift_down(self, idx):
# left_child = 2 * idx + 1
# if left_child >= len(self._buffer):
# return
# smaller_child = left_child
# right_child = left_child + 1
# if right_child < len(self._buffer):
# if self._buffer[right_child] < self._buffer[left_child]:
# smaller_child = right_child
# if self._buffer[smaller_child] < self._buffer[idx]:
# self._swap(smaller_child, idx)
# self._sift_down(smaller_child)
def _sift_down(self, idx):
while True:
left_child = idx * 2 + 1
if left_child >= len(self._buffer):
break
smaller_child = left_child
right_child = left_child + 1
if right_child < len(self._buffer):
if self._buffer[right_child] < self._buffer[left_child]:
smaller_child = right_child
if self._buffer[smaller_child] < self._buffer[idx]:
self._swap(idx, smaller_child)
else:
break
# this is used multiple times in different places, so use a separate function
def _swap(self, i, j):
self._buffer[i], self._buffer[j] = self._buffer[j], self._buffer[i]
def add(self, value):
self._buffer.append(value)
last_index = len(self._buffer) - 1
self._sift_up(last_index)
def extract_min(self):
if not self._buffer:
raise IndexError("Extract from empty heap")
minimum = self._buffer[0]
self._swap(0, -1)
self._buffer.pop()
self._sift_down(0)
return minimum
def __len__(self):
return len(self._buffer)
def __bool__(self):
return bool(self._buffer)
# for an implementation of __eq__ und __ne__
# see the standard library chapter
# heaps are not indexable, so do not implement this
# def __getitem__(self, key):
# return NotImplemented
Assignment OOP2¶
String-Repräsentation des Heaps Implementieren Sie
__str__
und__repr__
für den binären Min-Heap. Denken Sie dabei an den vorher besprochenen Contract für__repr__
-Implementierungen.
class MinHeap:
# never use mutable default parameters such as lists
def __init__(self, iterable=None):
self._buffer = []
if iterable is None:
return
for elem in iterable:
self.add(elem)
# def _sift_up(self, idx):
# if idx == 0:
# return
# parent = (idx - 1) // 2
# if self._buffer[parent] < self._buffer[idx]:
# return
# self._swap(idx, parent)
# self._sift_up(parent)
# _sift_up und _sift_down are implementation details
# and not part of the public interface
# this is why we prefix them with "_"
def _sift_up(self, idx):
# prefer this over a recursive solution because of stack overflows
while idx > 0:
parent = (idx - 1) // 2
# this requires only __lt__ to be defined!
if self._buffer[parent] < self._buffer[idx]:
break
self._swap(idx, parent)
idx = parent
# def _sift_down(self, idx):
# left_child = 2 * idx + 1
# if left_child >= len(self._buffer):
# return
# smaller_child = left_child
# right_child = left_child + 1
# if right_child < len(self._buffer):
# if self._buffer[right_child] < self._buffer[left_child]:
# smaller_child = right_child
# if self._buffer[smaller_child] < self._buffer[idx]:
# self._swap(smaller_child, idx)
# self._sift_down(smaller_child)
def _sift_down(self, idx):
while True:
left_child = idx * 2 + 1
if left_child >= len(self._buffer):
break
smaller_child = left_child
right_child = left_child + 1
if right_child < len(self._buffer):
if self._buffer[right_child] < self._buffer[left_child]:
smaller_child = right_child
if self._buffer[smaller_child] < self._buffer[idx]:
self._swap(idx, smaller_child)
else:
break
# this is used multiple times in different places, so use a separate function
def _swap(self, i, j):
self._buffer[i], self._buffer[j] = self._buffer[j], self._buffer[i]
def add(self, value):
self._buffer.append(value)
last_index = len(self._buffer) - 1
self._sift_up(last_index)
def extract_min(self):
if not self._buffer:
raise IndexError("Extract from empty heap")
minimum = self._buffer[0]
self._swap(0, -1)
self._buffer.pop()
self._sift_down(0)
return minimum
def __len__(self):
return len(self._buffer)
def __bool__(self):
return bool(self._buffer)
# for an implementation of __eq__ und __ne__
# see the standard library chapter
# heaps are not indexable, so do not implement this
# def __getitem__(self, key):
# return NotImplemented
# solution to assignment
def __str__(self):
return repr(self)
# solution to assignment
def __repr__(self):
# return f"<MinHeap with {len(self)} entries>"
# this is only possible if MinHeap provides an appropriate constructor
return f"MinHeap({self._buffer!r})"
Assignment OOP3¶
Internals von
range
🧑🏫 Implementieren Sie eine KlasseMyRange
, die wierange
funktionieren soll. Aus Gründen der Einfachheit beschränken wir uns auf positive Werte fürskip
. Orientieren Sie sich an folgender Vorgabe:
class MyRange:
# MyRange(end)
# MyRange(start, end)
# MyRange(start, end, skip)
def __init__(...):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__len__
def __len__(self):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__str__
def __str__(self):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__repr__
def __repr__(self):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__contains__
def __contains__(self, item):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__hash__
def __hash__(self):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__eq__
def __eq__(self, other):
pass
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__ne__
def __ne__(self, other):
pass
# r = MyRange(20)
# r[4] -> key has value 4
# r[:5:-1] -> key has value slice(None, 5, -1)
# use isinstance(key, slice) to check for slices
# https://docs.python.org/3/reference/datamodel.html?highlight=__repr__#object.__getitem__
def __getitem__(self, key):
print(key)
# Slices still buggy
class MyRange:
# MyRange(end)
# MyRange(start, end)
# MyRange(start, end, skip)
def __init__(self, start, end=None, skip=1):
if end == None:
end = start
start = 0
tmp = (end - start) // skip
end = start + tmp * skip
self._start = start
self._end = end
self._skip = skip
def __len__(self):
return (self._end - self._start) // self._skip
def __str__(self):
return f"Range({self._start},{self._end},{self._skip})"
def __repr__(self):
return f"Range({self._start},{self._end},{self._skip})"
def __contains__(self, item):
if item < self._start or item >= self._end:
return False
if ((item - self._start) % self._skip) == 0:
return True
return False
def __hash__(self):
return hash((self._start, self._end, self._skip))
def __eq__(self, b):
return self._start == b._start and self._end == b._end and self._skip == b._skip
def __ne__(self, b):
return not (self == b)
# r = MyRange(20)
# r[4] -> key has value 4
# r[:5:-1] -> key has value slice(None, 5, -1)
# use isinstance(key, slice) to check for slices
def __getitem__(self, key):
if not isinstance(key, slice):
if len(self) <= key:
raise IndexError("object index out of range")
return self._start + key * self._skip
start = key.start if key.start else 0
stop = key.stop if key.stop else -1
step = key.step if key.step else 1
new_start = (
self._end + start * self._skip
if start < 0
else self._start + self._skip * start
)
new_end = (
self._end + stop * self._skip
if stop < 0
else self._start + self._skip * stop
)
return MyRange(new_start, new_end, self._skip * step)
Functions as Values¶
def largest_number(numbers):
if not numbers:
raise ArgumentError("Empty List")
# largest = numbers[0] only works with indexable structures
largest = None
for n in numbers:
if largest is None or n > largest:
largest = n
return largest
# this is the best way to do it in Python
def largest_number(numbers):
return max(numbers)
# functions can be assigned to variables
largest_number = max
# see BAS2
def longest_string(strings):
if not strings:
raise ArgumentError("Empty List")
longest = None
length = -1
for s in strings:
if len(s) > length:
length = len(s)
longest = s
return longest
def string_length(s):
return len(s)
# this is the best way to do it in Python
def longest_string(strings):
# the use of a "key" argument instead of a comparator stems from SQL
return max(strings, key=string_length)
def longest_string(strings):
return max(strings, key=len)
def find_string_where_letter_a_is_most_frequent(strings):
if not strings:
raise ArgumentError("Empty List")
best = None
max_count = -1
for s in strings:
count = s.count("a")
if count > max_count:
max_count = count
best = s
return best
def count_a(s):
return s.count("a")
# this is the best way to do it in Python
def find_string_where_letter_a_is_most_frequent(strings):
return max(strings, key=count_a)
# lambda functions implicitly return the value
# this reduces the function to its bare minimum
count_a = lambda s: s.count("a")
def find_string_where_letter_a_is_most_frequent(strings):
return max(strings, key=count_a)
# lambdas can be used like any other value
def find_string_where_letter_a_is_most_frequent(strings):
return max(strings, key=lambda s: s.count("a"))
# this is a generalized version
def find_string_where_letter_is_most_frequent(strings, letter):
if not strings:
raise ArgumentError("Empty List")
best = None
max_count = -1
for s in strings:
count = s.count(letter)
if count > max_count:
max_count = count
best = s
return best
def find_string_where_letter_is_most_frequent(strings, letter):
# the lambda function captures "letter" from the enclosing scope
return max(strings, key=lambda s: s.count(letter))
# can you write this without using lambdas?
# this does not work
def count(s, letter):
return s.count(letter)
def find_string_where_letter_is_most_frequent(strings, letter):
return max(strings, key=count)
# stacked functions work
def count(letter):
def count_letter(s):
return s.count(letter)
# functions can be returned by other functions
return count_letter
# as such, they require multiple invocations
count("l")("hallo")
2
def find_string_where_letter_is_most_frequent(strings, letter):
return max(strings, key=count(letter))
# local definition also works
def find_string_where_letter_is_most_frequent(strings, letter):
def count_letter(s):
return s.count(letter)
return max(strings, key=count_letter)
# this is what stacked lambdas would look like
count = lambda letter: lambda s: s.count(letter)