Language Features II

Start in JupyterHub

Properties

Properties are useful when you want to disguise a function as a variable. This allows cleaner abstractions over the internal representation. In the following example, we use properties to provide access to cartesian and polar coordinates of a point in two-dimensional space. To the user, it looks like Point2DMutable is storing x, y, r and phi all at once. However, Point2DMutable only stores the cartesian representation since storing both representation would be redundant. Note that the conversion taking place in the background can lead to numerical instability in some situations.

import math

class Point2DMutable:
    __slots__ = '_x', '_y'
    # no defaults!
    def __init__(self, x, y):
        # no redundant state
        self._x = x
        self._y = y
    
    def __str__(self):
        return f"{self._x}, {self._y}"
    
    def __repr__(self):
        return f"Point2DMutable({self._x}, {self._y})"
    
    def __eq__(self, other):
        return (self._x, self._y) == (other._x, other._y)
    
    def __ne__(self, other):
        return not self.__eq__(other)
    
    def dist(self, p):
        return math.hypot(self._x - p._x, self._y - p._y)
    
    @staticmethod
    def from_polar(r, phi):
        point = Point2DMutable(0, 0)
        point._set_from_polar(r, phi)
        return point
    
    def _set_from_polar(self, r, phi):
        self._x = math.cos(phi) * r
        self._y = math.sin(phi) * r
    
    @property
    def x(self):
        return self._x
    
    @x.setter
    def x(self, x):
        self._x = x

    @property
    def y(self):
        return self._y
        
    @y.setter
    def y(self, y):
        self._y = y
        
    @property
    def r(self):
        return math.hypot(self._x, self._y)
        
    @r.setter
    def r(self, r):
        self._set_from_polar(r, self.phi)
        
    @property
    def phi(self):
        return math.atan2(self._y, self._x)
        
    @phi.setter
    def phi(self, phi):
        self._set_from_polar(self.r, phi)
p = Point2DMutable(2, 3)
p.x
2
p.r *= 2
p.x
4.000000000000001
p.phi = 0
p
Point2DMutable(7.21110255092798, 0.0)

Assignment LF5

  1. Getter ersetzen Ersetzen Sie die Getter in MyFraction durch Properties.

  1. KNN Schreiben Sie eine Funktion, die eine Liste von Punkten, einen Punkt p und eine Zahl k nimmt und aus der Liste von Punkten k Punkte heraussucht, die die kleinste Distanz zu p haben.

import heapq

def k_nearest_neighbors(point, k, candidates):
    return heapq.nsmallest(k, candidates, key=point.dist)
points = [
    Point2DMutable(1, 2),
    Point2DMutable(2, 3),
    Point2DMutable(5, 7),
    Point2DMutable(7, 5),
    Point2DMutable(3, 10),
    Point2DMutable(12, -2),
    Point2DMutable(2, 15),
]
k_nearest_neighbors(Point2DMutable(0, 0), 4, points)
[Point2DMutable(1, 2),
 Point2DMutable(2, 3),
 Point2DMutable(5, 7),
 Point2DMutable(7, 5)]

Context Managers

https://docs.python.org/3/library/stdtypes.html#typecontextmanager

The primary use of Context Managers is to properly dispose of resources or handle exceptions automatically. open returns a Context Manager that automatically closes the file when the with block ends.

Context Managers can be used as implicit parameters or as a local modification of module-global state:

import myfraction

frac = myfraction.MyFraction()
with myfraction.string_style(myfraction.DECIMAL):
    print(frac)

Be careful when using global state with multithreading!

import math
import functools


# 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
    
    # 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)
frac = MyFraction(1, 3)
print(frac)
with string_style(DECIMAL):
    print(frac)
print(frac)
1/3
0.3333333333333333
1/3

Decorators

Decorators can be used to modify a function or class after it has been created. Note that some language features such as @classmethod and @staticmethod are also using decorator syntax.

import functools

def print_args_and_result(func):
    # see functools section
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__} arguments: {args}")
        print(f"{func.__name__} keyword arguments: {kwargs}")
        print(f"{func.__name__} result: {result}")
        return result
    return wrapper
# see list comprehensions chapter

@print_args_and_result
def cross_powers(bases, exponents):
    return [b ** e for b in bases for e in exponents]

@print_args_and_result
def cross_powers_with_nonnegative_exponent(bases, exponents):
    nonnegative_exponents = [e for e in exponents if e >= 0]
    return cross_powers(bases, nonnegative_exponents)

cross_powers_with_nonnegative_exponent([2, 3, 5], [-2, -1, 4, 7])
cross_powers arguments: ([2, 3, 5], [4, 7])
cross_powers keyword arguments: {}
cross_powers result: [16, 128, 81, 2187, 625, 78125]
cross_powers_with_nonnegative_exponent arguments: ([2, 3, 5], [-2, -1, 4, 7])
cross_powers_with_nonnegative_exponent keyword arguments: {}
cross_powers_with_nonnegative_exponent result: [16, 128, 81, 2187, 625, 78125]
[16, 128, 81, 2187, 625, 78125]
import functools
import io
import sys

class PrintIndenter:
    def __init__(self, indent_size=4):
        self._indent_size = indent_size
        self._current_depth = 0
        
    def _pad(self, string):
        padding = " " * self._current_depth
        return padding + string.replace("\n", "\n" + padding)
        
    def print_(self, *items):
        buf = io.StringIO()
        # print to buffer
        printed = print(*items, file=buf, end='')
        # pad buffer
        result = self._pad(buf.getvalue())
        print(result)
    
    def with_indent(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            self._current_depth += self._indent_size
            result = func(*args, **kwargs)
            self._current_depth -= self._indent_size
            return result
        return wrapper
indenter = PrintIndenter(2)

# this is what decorators look like without the fancy @ syntax
# fib = indenter.with_indent(fib)

@indenter.with_indent
def fib(n):
    indenter.print_(n)
    return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(5)
  5
    4
      3
        2
          1
          0
        1
      2
        1
        0
    3
      2
        1
        0
      1
5

Aufgabe LF6 (WIP)

  1. Veränderbarer Bruch Schreiben Sie MyFraction so um, dass Brüche veränderbar sind. Passen Sie die Properties num und denom so an, dass diese auch schreibbar sind. Überschreiben Sie außerdem die In-Place-Operatoren, z.B. __iadd__. Vergessen Sie nicht, die Brüche immer gekürzt zu halten.