Square CoCalc Logo
FeaturesSoftwarePricingInfoPoliciesShareSupport Try Sign InSign Up
Project: admcycles
Views: 28
Visibility: Unlisted (only visible to those who know the link)
Image: ubuntu2004
| Embed | Download | Raw
Kernel: SageMath 9.1

Lecture 7 : Classes and algebraic structures

References:

Summary:
In this lecture, we discuss how to define new classes in Python (and SageMath). We learn how to give them useful methods and how to inherit from other classes. We discuss how to implement arithmetic operations in Python and, if time permits, how to use the parent/element framework in SageMath to implement more general algebraic structures there.

A brief overview of classes

Without discussing it in much detail, we have already used many examples of classes defined in SageMath. Take the following example:

In [ ]:
a = 6/5
In [ ]:
type(a)

We see that there exists a class (namely Rational, defined in sage.rings.rational) which implements rational numbers. The concrete object a that we created is an instance of this class.

One of the things that we can do now is use methods of the class above, for the specific instance a that we created:

In [ ]:
a.factor()

The code for this particular method forms part of the class Rational, and we can have a look at the corresponding code:

In [ ]:
a.factor??

Now we want to learn how to create such nice classes for ourselves!

Creating new classes

Our running example will be a toy implementation of the complex numbers. Here is a first basic version:

In [ ]:
class MyCompNumb: def __init__(self, RealPart, ImaginaryPart): self.Re = RealPart self.Im = ImaginaryPart

That's certainly very basic! Still, we see some of the important structure:

  • We begin with the keyword class followed by the name that we choose and a :. In the following indented block, we can define methods of our new class.

  • The most fundamental method is always called __init__, which is used when creating new instances of our class (i.e. when we create a new object representing a complex number). Its first argument is always self, which will be this new instance, followed by some more arguments. In the case above, these are the inputs RealPart, ImaginaryPart of the real and imaginary part of our complex number. What our concrete method above does is simply to store these values inside self as attributes self.Re, self.Im.

Let's see this class in "action". First, we create a new instance, representing the number 1+2i1+2\cdot i, and call it z:

In [ ]:
z = MyCompNumb(1, 2) type(z)

Our class cannot do anything interesting yet, but we can access the internal attributes Re, Im:

In [ ]:
z.Re

These more or less work like any variables that we used before, e.g. we can also change their values:

In [ ]:
z.Re = 3 z.Re

Most of the rest of the lecture, we will discuss how to make this class more interesting and usable.

Writing methods

The main feature of classes is that we can give them methods, i.e. functions defined inside the class, which we can call like a.factor() above. Here is a first small addition to our toy class:

In [ ]:
class MyCompNumb: def __init__(self, RealPart, ImaginaryPart): self.Re = RealPart self.Im = ImaginaryPart def norm(self): return sqrt(self.Re^2 + self.Im^2)
In [ ]:
z = MyCompNumb(1, 2) z.norm()

As you see, the first argument of any method is always called self and contains the object calling the method.

Exercise

Create the complex number 3+4i3+4i and compute its norm.

Solution (uncomment to see)

Exercise

Add a method complex_conjugate to the class above, which returns the complex conjugate of the number.

Solution (uncomment to see)

When we try to test the new code, we encounter a problem:

w = MyCompNumb(3,4) w.complex_conjugate() > <__main__.MyCompNumb object at 0x6fd0d766f60>

In order to get a useful, readable output, we need to tell the class how an instance should be represented when trying to print it. To do this, we can define a method __repr__. It is supposed to return a string which represents the value of the object. Then, when writing print(a) (or just a), it secretly calls print(a.__repr__()).

In [ ]:
class MyCompNumb: def __init__(self, RealPart, ImaginaryPart): self.Re = RealPart self.Im = ImaginaryPart def norm(self): return sqrt(self.Re^2 + self.Im^2) def complex_conjugate(self): return MyCompNumb(self.Re, -self.Im) def __repr__(self): return repr(self.Re)+' + '+repr(self.Im)+' * i'
In [ ]:
w = MyCompNumb(3,4) w

This looks reasonable on first glance, but testing a few more examples, we quickly see some issues:

In [ ]:
w.complex_conjugate()
In [ ]:
MyCompNumb(0,6)

One of the assignments is to write a better version of the __repr__ function, but for now the version above is good enough.

Exercise

Write a function arg which computes the argument of the complex number, i.e. its angle with the positive real axis measured in the interval (π,π](-\pi, \pi]. If you are done early: add the optional argument max_arg so that the argument is instead specified in the interval from max_arg - 2 pi to max_arg.

Solution (uncomment to see)

Before moving on, we'll mention one more trick associated to methods of classes, called monkey patching. The idea is that instead of declaring a method at the definition of a class, you can also attach it later, as follows:

In [ ]:
def is_nth_root_of_unity(self, n): phi = self.arg() return self.norm()==1 and simplify(phi/(2*pi)*n).is_integer() MyCompNumb.is_nth_root_of_unity = is_nth_root_of_unity
In [ ]:
z = MyCompNumb(0,1) z.is_nth_root_of_unity(4)
In [ ]:
z = MyCompNumb(1/sqrt(2),1/sqrt(2)) z.is_nth_root_of_unity(8)

This should be done with some care, but in some cases (e.g. in exercises below) it can help to not always repeat all the code we already had before.

Inheritance of classes

Sometimes we don't want to create a new class from scratch, but instead just add some feature to an existing class. In this case, we can let the new class inherit from the old one. This means that, by default, the methods and attributes of the old class are transferred to the new one, but one can add additional features (or overwrite methods from the old class).

As an example, let's create a variant of the class MyCompNumb above, which in addition to the real and imaginary part remembers explicitly one choice of argument. Here is a way to do this:

In [ ]:
class MyCompNumbWithArg(MyCompNumb): def __init__(self, RealPart, ImaginaryPart, Phi): MyCompNumb.__init__(self, RealPart, ImaginaryPart) self.Phi = Phi def complex_conjugate(self): return MyCompNumbWithArg(self.Re, -self.Im, -self.Phi) def __repr__(self): return repr(self.Re)+' + '+repr(self.Im)+' * i with arg = '+repr(self.Phi)
In [ ]:
z = MyCompNumbWithArg(1,1,pi/4); z

What we see here:

  • The class from which we inherit (the parent class) is given in parentheses behind the name of the new class (the child class). We could also specify multiple parent classes (e.g. it is very often advisable to inherit from SageObject to get some basic methods like the option to save into a file). Apart from taking over the methods from the parents, the instances of the new class also count as instances of the old class (every complex number with argument is still an example of a complex number):

In [ ]:
isinstance(z, MyCompNumb)
  • We inherited the methods from the old function, which is why the following works:

In [ ]:
z.norm()
  • On the other hand, we have overwritten the functions __init__ and __repr__ to adapt to the additional argument Phi. Note that by calling MyCompNumb.__init__ we make sure that all the attributes set in MyCompNumb will also be available in MyCompNumbWithArg.

  • Additionally, we have modified the function complex_conjugate. Otherwise, it would return instances of MyCompNumb and not MyCompNumbWithArg.

Here are a few things we can now do with our class:

Exercise

  • Modify the __init__ method of MyCompNumbWithArg to include a check that the given argument Phi actually makes sense (e.g. something like MyCompNumbWithArg(1,0,pi) should not be allowed). Raise a ValueError if it does not make sense.

  • Modify the method arg to give back the chosen argument (instead of the one in (π,π](-\pi, \pi]).

  • Write a method log which makes sense for complex numbers with an argument.
    Mathematical hint: What is log(z)\log(z) for z=reiϕz=r e^{i \phi}?
    Philosophical hint: Is log(z)\log(z) naturally a complex number, or a complex number with an argument?
    Python hint: Given a real number r you can compute its logarithm with r.log().

Solution (uncomment to see)

Arithmetic operations

One thing we of course want to do with complex numbers is to add, subtract, multiply and divide them. This can again be done using special methods, called __add__, __sub__, __mul__. Unfortunately, these must be present in the original declaration of the class (and cannot be monkey patched).

In [ ]:
# For exercises below, add new methods here: class MyCompNumb: def __init__(self, RealPart, ImaginaryPart): self.Re = RealPart self.Im = ImaginaryPart def norm(self): return sqrt(self.Re^2 + self.Im^2) def complex_conjugate(self): return MyCompNumb(self.Re, -self.Im) def __repr__(self): return repr(self.Re)+' + '+repr(self.Im)+' * i' def arg(self): if self.Re == 0 and self.Im == 0: raise ValueError('Argument only defined for nonzero complex numbers') if self.Im >= 0: result = arccos(self.Re / self.norm()) else: result = -arccos(self.Re / self.norm()) return result def __add__(self, other): return MyCompNumb(self.Re + other.Re, self.Im + other.Im)

Exercise

Write the functions __sub__, __mul__ and __truediv__ for the multiplication, subtraction and division of complex numbers and test them in some examples.
If you are done early, you can also do __pow__ for the "raise to power" function (say, for integer exponents).

Solution (uncomment to see)

See here for more on the Python perspective on arithmetic operations.

Playing around - a class for rectangles

Let's take a bit of time and practise all of the things above in a new example.

Exercise

Write a class Rectangle which implements rectangles in the plane R2\mathbb{R}^2 with sides parallel to the xx and yy-axis. Think about some interesting functions that you could give this class, and implement them. Here is a bit of inspiration of what this class could do:

R = Rectangle(0,2,-3,5); R > Rectangle [0, 2] x [-3, 5] R.area() > 16 R + (-1,-1) > Rectangle [-1, 1] x [-4, 4] R.is_square() > False

Solution (uncomment to see)

Implementing algebraic structures in SageMath

Above we learned how to do basic arithmetic with our handmade complex numbers. However, many natural operations that we would hope to have will still not work. Here are a few examples:

In [ ]:
z = MyCompNumb(2,0) 3+z
In [ ]:
z == 2 # maybe should be True
In [ ]:
z.is_zero()

In each case, the problem could be fixed in principle:

  • One could extend the methods __add__ etc. to first check if other is maybe an Integer / Rational etc and have special methods for each of these (and maybe have also methods __radd__, __rmul__ etc).

  • Similarly, one can implement the method __eq__ for checking equality.

  • One can by hand write a method is_zero.

However, as you can imagine, this would be a lot of work and the resulting code could easily break if the user applies it in a situation you did not foresee.

A better way to do this is to use the coercion and category framework in SageMath. See also the primer on categories, parents and elements in SageMath. A rough overview, explained with the example of the actual complex numbers CC in SageMath:

  • Instead of only having a class for complex numbers, one would have two classes: a parent ComplexField_class and an element class ComplexNumber.

  • New elements are created via an instance of the parent class:

In [ ]:
from sage.rings.complex_field import ComplexField_class CC = ComplexField_class(42); CC
In [ ]:
z = CC(1,3); z

These elements then remember their parents:

In [ ]:
z.parent()
  • For the parent class, you need to program some method which converts reasonable other data types (int, float, Rational, ...) into elements of that parent class:

In [ ]:
CC(0.43)
In [ ]:
CC(int(2))
In [ ]:
CC(4/5)
  • Then, if you told your child class how to add, multiply, etc its elements (via functions _add_, _mul_, ...) and it encounters something of the form int + ComplexNumber, it will silently convert (coerce) the other class into a complex number, and then do the arithmetic operation:

In [ ]:
z + int(6)

This also works for things like the comparison that we saw above:

In [ ]:
w = CC(2) w == 2
  • Even better, since many classes have a functorial definition in SageMath, it is in some cases possible to have arithmetic operations between elements of parents A,B such that neither can be converted into the other. The idea is to find a common parent C, convert both elements into this, and then perform the arithmetic there. Take the following example:

In [ ]:
a = CC(1,3); a
In [ ]:
R.<x,y> = PolynomialRing(QQ,2) b = x^2 + 3*y; b
In [ ]:
c = a+b; c
In [ ]:
c.parent()
  • Your parent and child class should inherit from some general classes (in our case Field and FieldElement). This gives them many useful methods, which you don't have to worry about at all. Look at the code of the following functions:

In [ ]:
CC.is_field??
In [ ]:
CC.zero??

Then, if you have implemented a function __eq__ for checking equality and call z.is_zero(), it will just check whether

z == z.parent().zero()
  • Moreover, via the category framework we have that SageMath knows about properties of its parent (and element) classes, and how they behave under basic operations. For instance, since CC is a field, we know that CC[x,y] is an integral domain:

In [ ]:
CC[x,y].is_integral_domain()

To summarize: by working with parent/child classes and the coercion and category framework it is possible to save yourself a lot of work when implementing a new algebraic structure, and many constructions work almost by magic.

However, this is also a relatively special problem, and explaining all the details goes beyond the scope of this lecture. Still, if you plan to implement a particular type of group/module/ring/etc as your final project, it might be worth to look into this further!

Assignments

Exercise

For the class MyCompNumb above, write a good method __repr__ which also works well for negative/vanishing real and imaginary parts. Test whether this method works for all combinations of (sign(Re),sign(Im))(\mathrm{sign}(Re), \mathrm{sign}(Im)).

Solution (uncomment to see)

Exercise

Write a method is_in_quadrant which takes a number quad from 11 to 44 and says whether the complex number is in the corresponding quadrant of the plane of complex numbers (recall that the numbers with positive real and imaginary value are the first quadrant, and then it goes in counter-clockwise order).

Solution (uncomment to see)