3. Objects and abstraction

In this chapter we will take a first look at the representation of abstract mathematical objects and operations as data objects in a computer program. We will learn about what it means for objects to have a type, and how to create new types using the class keyword.

3.1. Abstraction in action

Consider this line of Python code:

print(a + b)

What does it do? Well, assuming that a and b are suitably defined, it prints their sum. This, however, begs the questions: what is “suitably defined”, and what is “sum”? For example:

In [1]: a = 1
In [2]: b = 2
In [3]: print(a + b)
3

You’re unlikely to be surprised that Python can add integers. On the other hand it turns out we can also add strings:

In [1]: a = 'fr'
In [2]: b = 'og'
In [3]: print(a + b)
'frog'

So the meaning of + depends on what is being added. What happens if we add an integer to a string?

In [1]: a = 1

In [2]: b = 'og'

In [3]: print(a + b)
--------------------------------------------------------------------------
TypeError                                Traceback (most recent call last)
Cell In [3], line 1
----> 1 print(a + b)

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

In this error, Python is complaining that + does not make sense if the items being added (the operands) are an integer and a string. This makes our understanding of “suitably defined” more concrete: clearly some pairs of objects can be added and others can’t. However, we should be careful about the conclusions we draw. We might be tempted to believe that we can add two values if they are of the same type. However, if we try this with a pair of sets then we’re also in trouble:

In [1]: a = {1, 2}

In [2]: b = {2, 3}

In [3]: print(a + b)
--------------------------------------------------------------------------
TypeError                                Traceback (most recent call last)
Cell In [3], line 1
----> 1 print(a + b)

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

Conversely we might suspect that two values can be added only if they are of the same type. However it is perfectly legal to add an integer and a floating point value:

In [1]: a = 1
In [2]: b = 2.5
In [3]: print(a + b)
3.5

In Python, the operator + encodes an abstraction for addition. This means that + stands for the addition operation, whatever that may mean for a particular pair of operands. For the purposes of the abstraction, everything which is specific to the particular operands is ignored. This includes, for example, the mechanism by which the addition is calculated and the value of the result. This enables a programmer to think about the relatively simple mathematical operation of addition, rather than the potentially complex or messy way it might be implemented for particular data.

Definition 3.1

An abstraction is a mathematical object with a limited set of defined properties. For the purposes of the abstraction, any other properties that an object may have are disregarded.

An abstraction is a purely mathematical concept, but it is one which maps to one or more concrete realisations in code. Sometimes the abstract mathematical concept and its concrete realisation match so perfectly that it is difficult to distinguish the two. In those circumstances, we usually conflate the terminology for the abstraction and the code object. “Type” is one such example, and we turn to that now.

3.2. Types

In the previous section, we observed that addition may or may not be defined, depending on what the types of its operands are. In doing so, we skirted the question of what it means for an object to have type.

Definition 3.2

A type or class is an abstraction defined by a set of possible values, and a set of operators valid for objects of that type.

Every object in Python has a type. This is true for primitive numeric types, such as int, float, and complex; for sequences such as string (str), tuple, and list; and also for more complex types such as set and dictionary (dict). Indeed, the Python concept of type goes much further, as we discover if we call type on various objects:

In [1]: type(1)
Out[1]: int
In [2]: type(abs)
Out[2]: builtin_function_or_method

So 1 is an object of type int, which means that it comes with all of Python’s operations for integer arithmetic. abs(), on the other hand, is a built-in function, so its defining operation is that it can be called on one or more suitable arguments (for example abs(1)). If every object has a type, what about types themselves? What is the type of int?

In [1]: type(int)
Out[1]: type

We see that int is the type of integer objects, and is itself an object with type type. That rather invites the question of what is the type of type?

In [1]: type(type)
Out[1]: type

This actually makes perfect sense, because type is simply the type of types.

We will return to types in much more detail later. At this stage, the take-home message is that essentially everything you will encounter in Python is an object, and every object has a type.

Note

In Python, the term “class” is essentially synonymous with “type”, so “what is the class of foo” is the same as saying “what is the type of foo”. However the two terms are not synonyms when used in code. type can be used to determine the type of an object, while class is used to define new types.

3.3. Defining new types

Video: a first class

Imperial students can also watch this video on Panopto

Python has a rich set of built-in types. These form powerful building blocks for the language, but one very soon finds mathematical abstractions which do not have implementations among the built-in types of the Python interpreter. For example, the built-in types do not include a matrix or multidimensional array type. The ability to make new data types which provide concrete implementations of further mathematical abstractions is central to effectively exploiting abstraction in programming.

As an example, lets suppose that we want to work with real polynomials in one variable. That is to say, functions of the form:

\[f(x) = \sum_{n=0}^d c_n x^n \quad \textrm{for some } d\in \mathbb{N}, c_n \in \mathbb{R}\]

The set of all polynomials is a well-defined (though infinite) set of different values, with a number of well-defined properties. For example, we can add and multiply polynomials, resulting in a new polynomial. We can also evaluate a polynomial for a particular value of \(x\), which would result in a real value.

This is the mathematical abstraction of a polynomial. How would we represent this abstraction in Python code? A polynomial is characterised by its set of coefficients, so we could in principle represent a polynomial as a tuple of coefficient values. However, the addition of tuples is concatenation, and multiplication of two tuples isn’t even defined, so this would be a very poor representation of the mathematics: a polynomial represented as a tuple of coefficients would not behave the way a mathematician would expect. Instead, what we need to do is make a new type whose operations match the mathematical properties of a polynomial.

3.3.1. Classes and constructors

The Python keyword for declaring a new type is class. Just like a function declaration, this creates a new indented block. In this case, the block contains all of the function declarations which define the operations on this new type. Let’s make a very simple implementation:

class Polynomial:

  def __init__(self, coefs):
      self.coefficients = coefs

We’ll interpret the \(i\)-th coefficient as the coefficient of \(x^i\). This will simplify the program logic, but take care because mathematicians usually write coefficients from largest power of \(x\) to smallest, and this is the opposite of that. Executing this code in a Python interpreter would enable us to create a simple polynomial, and inspect its coefficients:

In [7]: f = Polynomial((0, 1, 2))
In [8]: f.coefficients
Out[8]: (0, 1, 2)

The three lines of Python defining the Polynomial class contain several important concepts and Python details that it is important to understand.

The class definition statement opens a new block, so just like a function definition, it starts with the keyword followed by the name of the class we are defining, and ends with a colon. User-defined classes in Python (i.e. classes not built into the language) usually have CapWords names. This means that all the words in the name are capitalised and run together without spaces. For example, if we decided to make a separate class for complex-valued polynomials, we might call it ComplexPolynomial.

Inside the class definition, i.e. indented inside the block, is a function called __init__(). Functions defined inside a class definition are called methods. The __init__() method has a rather distinctive form of name, starting and ending with two underscores. Names of this format are used in the Python language for objects which have special meaning in the Python language. The __init__() method of a class has special meaning in Python as the constructor of a class. When we write:

In [7]: f = Polynomial((0, 1, 2))

This is called instantiating an object of type Polynomial. The following steps occur:

  1. Python creates an object of type Polynomial.

  2. The __init__() special method of Polynomial is called. The new Polynomial object is passed as the first parameter (self), and the tuple (0, 1, 2) is passed as the second parameter (coefs).

  3. The name f in the surrounding scope is associated with the Polynomial.

Note

Notice that Polynomial.__init__() doesn’t return anything. The role of the __init__() method is to set up the object, self; it is not to return a value. __init__() never returns a value.

3.3.2. Attributes

Let’s now look at what happened inside the __init__() method. We have just one line:

self.coefficients = coefs

Remember that self is the object we are setting up, and coefs is the other parameter to __init__(). This line of code creates a new name inside this Polynomial object, called coefficients, and associates this new name with the object passed as the argument to the Polynomial constructor. Names such as this are called attributes. We create an attribute just by assigning to it, and we can then read back the attribute using the same syntax, which is what we did here:

In [8]: f.coefficients
Out[8]: (0, 1, 2)

Attributes can be given any name which is allowed for a Python name in general - which is to say sequences of letters, numbers and underscores starting with a letter or an underscore. Special significance attaches to names starting with an underscore, so these should be avoided in your own names unless you intend to create a private attribute.

3.3.3. Methods

Video: defining methods

Imperial students can also watch this video on Panopto

We have already met the special method __init__(), which defines the class constructor. A much more typical case is an ordinary method, without a special underscore name. For example, suppose we wish to be able to access the degree of a polynomial, then we might add a degree() method to our class:

class Polynomial:

  def __init__(self, coefs):
      self.coefficients = coefs

  def degree(self):
      return len(self.coefficients) - 1

Observe that the new method is indented inside the class block at the same level as the __init__() method. Observe also that it too takes self as its first parameter. A key difference from the __init__() method is that degree() now returns a value, as most functions do. We can now use our new method to recover the degree of our polynomial.

In [1]: f = Polynomial((0, 1, 2))
In [2]: f.degree()
Out[2]: 2

To clarify the role of the self parameter it helps to understand that f.degree() is just a short way of writing Polynomial.degree(f). Like attributes, methods can have any allowed Python name. Attributes and methods on an object form part of the same namespace, so you can’t have an attribute and a method with the same name. If you try, then the name will be overwritten with whichever was defined later, and the attribute or method defined first will no longer be accessible under that name. This is unlikely to be what you wanted.

Note

The object itself is always passed as the first argument to a method. Technically, it is possible to name the first parameter any legal Python name, but there is a very strong convention that the first parameter to any instance method is called self. Never, ever name this parameter anything other than self, or you will confuse every Python programmer who reads your code!

3.3.4. String representations of objects

Video: printing classes

Imperial students can also watch this video on Panopto

Remember that a key reason for defining new classes is to enable users to reason about the resulting objects at a higher mathematical level. An important aid to the user in doing this is to be able to look at the object. What happens if we print a Polynomial?

In [1]: f = Polynomial((0, 1, 2))
In [2]: print(f)
<Polynomial object at 0x104960dd0>

This is less than useful. By default, Python just prints the class of the object and the memory address at which this particular object is stored. This is, however, not so surprising if we think about the situation in a little more depth. How was Python supposed to know what sort of string representation makes sense for this object? We will have to tell it.

The way we do so is using another special method. The special method name for the human readable string representation of an object is __str__(). It takes no arguments other than the object itself. Listing 3.1 provides one possible implementation of this method.

Listing 3.1 An implementation of the string representation of a Polynomial. This takes into account the usual conventions for writing polynomials, including writing the highest degree terms first, and omitting zero terms and unit coefficients.
 1def __str__(self):
 2
 3    coefs = self.coefficients
 4    terms = []
 5
 6    # Degree 0 and 1 terms conventionally have different representation.
 7    if coefs[0]:
 8        terms.append(str(coefs[0]))
 9    if self.degree() > 0 and coefs[1]:
10        terms.append(f"{coefs[1]}x")
11
12    # Remaining terms look like cx^d, though factors of 1 are dropped.
13    terms += [f"{'' if c == 1 else c}x^{d}"
14              for d, c in enumerate(coefs[2:], start=2) if c]
15
16    # Sum polynomial terms from high to low exponent.
17    return " + ".join(reversed(terms)) or "0"

This somewhat longer piece of code results from the fact that the linear and constant terms in a polynomial are usually represented slightly differently from the higher-order terms. Having added this new method to our class, we can now observe the result:

In [2]: f = Polynomial((1, 2, 0, 1, 5))
In [3]: print(f)
5x^4 + x^3 + 2x + 1

In fact, Python provides not one, but two special methods which convert an object to a string. __str__() is called by print() and also by str. Its role is to provide the string representation which is best understood by humans. In mathematical code, this will usually be the mathematical notation for the object. In contrast, the __repr__() method is called by repr() and also provides the default string representation printed out by the Python command line. By convention, __repr__() should return a string which a user might type in order to recreate the object. For example:

def __repr__(self):
    return type(self).__name__ + "(" + repr(self.coefficients) + ")"

type(self).__name__ simply evaluates to the class name, in this case Polynomial. This is better than hard-coding the class name because, as we will see in Chapter 7, this implementation of __repr__() might well end up being inherited by a class with a different name. Notice that in order to help ensure consistency of representations we call repr() on the coefficients in this case, whereas in the __str__() method we called str.

We can now observe the difference in the result:

In [2]: f = Polynomial((1, 2, 0, 4, 5))
In [3]: f
Out[3]: Polynomial((1, 2, 0, 4, 5))

When using f-strings, the repr() of a an object can be inserted instead of the str by using the !r modifier. For example, we could just as well have written the method above as:

def __repr__(self):
    return f"{type(self).__name__}({self.coefficients!r})"

3.3.5. Object equality

Video: object equality and test driven development

Imperial students can also watch this video on Panopto

When are two objects equal? For built-in types Python has equality rules which broadly match the mathematical identities that you might expect. For example, two numbers of different types are equal if their numerical value is equal:

In [1]: 2 == 2.0
Out[1]: True

In [2]: 2.0 == 2+0j
Out[2]: True

Similarly, intrinsic sequence types are equal when their contents are equal:

In [3]: (0, 1, "f") == (0., 1+0j, 'f')
Out[3]: True

In [4]: (0, 1, "f") == (0., 1+0j, 'g')
Out[4]: False

In [5]: (0, 1, "f") == (0., 1+0j)
Out[5]: False

This mathematically pleasing state of affairs doesn’t, however, automatically carry over to new classes. We might expect that two identically defined polynomials might compare equal:

In [6]: from example_code.polynomial import Polynomial

In [7]: a = Polynomial((1, 0, 1))

In [8]: b = Polynomial((1, 0, 1))

In [9]: a == b
Out[9]: False

The reason for this is obvious when one thinks about it: Python has no way to know when two instances of a new class should be considered equal. Instead, it falls back to comparing the unique identity of every object. This is accessible using the built-in function id():

In [10]: id(a)
Out[10]: 4487083344

In [11]: id(b)
Out[11]: 4488256096

This is a perfectly well-defined equality operator, but not a very mathematically useful one. Fortunately, Python allows us to define a more useful equality operator using the __eq__() special method. This takes the current object and the object it is being compared to, and returns True or False depending on whether the objects should be considered equal. When we write a == b in Python, what actually happens is a.__eq__(b).

A basic implementation of __eq__() that checks that the other object is a Polynomial with the same coefficients is:

def __eq__(self, other):
    return isinstance(other, Polynomial) and \
        self.coefficients == other.coefficients

Equipped with this method, Polynomial equality now behaves as we might expect.

In [1]: from example_code.polynomial import Polynomial

In [2]: a = Polynomial((1, 0, 1))

In [3]: b = Polynomial((1, 0, 1))

In [4]: a == b
Out[4]: True

3.3.6. Defining arithmetic options on objects

Video: polynomial addition.

Imperial students can also watch this video on Panopto

It’s all very well to be able to compare our polynomial objects, but we won’t really have captured the mathematical abstraction involved unless we have at least some mathematical operations. We have already observed that objects of some classes can be added. Is this true for Polynomials?

In [2]: a = Polynomial((1, 0))

In [3]: b = Polynomial((1,))

In [4]: a + b
--------------------------------------------------------------------------
TypeError                                Traceback (most recent call last)
Cell In [4], line 1
----> 1 a + b

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

Once again, this is not so surprising since we haven’t defined what addition of polynomials should mean. The special method which defines addition is __add__(). It takes the object itself and another object and returns their sum. That is, when you write a + b in Python, then what actually happens is a.__add__(b).

Before we define our addition method, we first need to consider what other objects it might make sense to add to a polynomial. Obviously, we should be able to add two polynomials, but it also makes sense to add a number to a polynomial. In either case, the result will be a new polynomial, with coefficients equal to the sum of those of the summands.

We also need to do something in the case where a user attempts to add to a polynomial a value for which the operation makes no sense. For example, a user might accidentally attempt to add a string to a polynomial. In this case, the Python language specification requires that we return the special value NotImplemented. Differentiating between the types of operands requires two more Python features we have not yet met. One of these is the built in function isinstance(), which tests whether an object is an instance of a class. The other is the class Number, which we import from the built-in numbers module. All Python numbers are instances of Number so this provides a mechanism for checking whether the other operand is a number. We will consider isinstance() and Number in more detail when we look at inheritance and abstract base classes.

Putting all this together, Listing 3.2 defines polynomial addition.

Listing 3.2 An implementation of addition for Polynomial.
 1def __add__(self, other):
 2    if isinstance(other, Number):
 3        return Polynomial((self.coefficients[0] + other,)
 4                          + self.coefficients[1:])
 5
 6    elif isinstance(other, Polynomial):
 7        # Work out how many coefficient places the two polynomials have in
 8        # common.
 9        common = min(self.degree(), other.degree()) + 1
10        # Sum the common coefficient positions.
11        coefs = tuple(a + b for a, b in zip(self.coefficients[:common],
12                                            other.coefficients[:common]))
13
14        # Append the high degree coefficients from the higher degree
15        # summand.
16        coefs += self.coefficients[common:] + other.coefficients[common:]
17
18        return Polynomial(coefs)
19
20    else:
21        return NotImplemented

Notice that we create a new Polynomial object for the result each time: the sum of two polynomials is a different polynomial, it doesn’t modify either polynomial in place.

Let’s try our new addition functionality in action:

In [2]: a = Polynomial((1, 2, 0, 1))

In [3]: print(a)
x^3 + 2x + 1

In [4]: b = Polynomial((0, 1))

In [5]: print(b)
x

In [6]: print(a + b)
x^3 + 3x + 1

In [7]: print(a + 1)
x^3 + 2x + 2

In [8]: print(1 + a)
--------------------------------------------------------------------------
TypeError                                Traceback (most recent call last)
Cell In [8], line 1
----> 1 print(1 + a)

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

So, everything proceeds as expected until we try to add a Polynomial to an integer. What happened? Remember that 1 + a causes Python to call int.__add__(1, a). What does that do?:

In [9]: int.__add__(1, a)
Out[9]: NotImplemented

Naturally, Python’s inbuilt int type knows nothing about our new Polynomial class, so when we ask it to do the addition, it returns NotImplemented. We could, however, tell Polynomial how to be added to an int, and Python provides a mechanism for this. If the __add__() of the left hand operand of + returns NotImplemented, then Python tries the reverse addition method, called __radd__(), on the right hand operand. Because we know that polynomial addition is commutative, we can define this very easily:

def __radd__(self, other):
    return self + other

With our newly enhanced Polynomial class, we can revisit the previously problematic operation:

In [2]: a = Polynomial((1, 2, 0, 1))

In [3]: print(1 + a)
x^3 + 2x + 2

Of course, addition is not the only arithmetic operator one might wish to overload. A fully featured polynomial class will, at the very minimum, need subtraction, multiplication (by a scalar or another polynomial) and exponentiation by an integer power. The combination of these, and particularly exponentiation, would allow the user to define new polynomials in a particularly natural way, using Python’s intrinsic operators:

In [1]: x = Polynomial((0, 1))

In [2]: print(x)
x

In [3]: p = x**3 + 2*x + 2

In [4]: p
Polynomial((2, 2, 0, 1))

The special method names for further arithmetic operators are given in the Python documentation. The implementation of multiplication, exponentiation, and subtraction for the Polynomial class is left as an exercise.

3.3.7. Creating objects that act like functions

From a mathematical perspective, a real polynomial is a function. That is to say, if:

\[f = x^2 + 2x + 1\]

then for any real \(x\), \(f(x)\) is defined and is a real number. We already know from the example of abs(), above, that Python functions are objects. However, our challenge is the converse of this: we have Polynomial objects which we would like to be able to call like functions. The solution to our challenge is that calling a function is an operation on an object similar to addition, and Python provides another special method name for this. f(x) is mapped to f.__call__(x), so any Python object with a __call__() method behaves like a function, and any class defining a __call__() method in effect defines a new type of function.

3.4. Encapsulation

The property that objects have of bundling up data and methods in a more-or-less opaque object with which other code can interact without concerning itself with the internal details of the object is called encapsulation. Encapsulation is one of the core concepts in object-oriented programming. In particular, encapsulation is key to creating single objects representing high level mathematical abstractions whose concrete realisation in code may require many pieces of data and a large number of complex functions.

3.5. Assignment and instantiation

One common confusion among new Python programmers concerns the distinction between making new objects, and assigning new names to existing objects. The key point to remember is that assignment in Python does not by itself create new objects, only new variable names. For example:

In [1]: a = set()

In [2]: b = a

The right hand side of the first line instantiates a new set, and the assignment creates the name a and associates it with the same set. The second line is just an assignment: it associates the name b with the same set. We can see the effect of this if we add an item to b and then look at the contents of a:

In [3]: b.add(1)

In [4]: print(a)
{1}

The same distinction between instantiating objects and making new references to them is the cause of a frequent mistake when trying to create a list of empty objects:

In [5]: c = [set()] * 5

In [6]: print(c)
[set(), set(), set(), set(), set()]

The programmer almost certainly intended to create a list containing five empty sets. Instead, they have created a list containing five references to the same set:

In [7]: c[0].add(2)

In [8]: print(c)
[{2}, {2}, {2}, {2}, {2}]

The right way to create a list of five empty sets is to use a list comprehension. This will instantiate a different set for each entry in the list:

In [9]: d = [set() for i in range(5)]

In [10]: d[0].add(2)

In [11]: print(d)
[{2}, set(), set(), set(), set()]

3.6. Glossary

abstraction

A mathematical concept with a limited set of defined properties. For the purposes of the abstraction, any other properties that an object may have are disregarded.

attribute

A value encapsulated in another object, such as a class. Attributes are accessed using dot syntax, so if b is an attribute of a then its value is accessed using the syntax a.b. Methods are a special case of attributes.

class
type

An abstraction defined by a set of possible values, and a set of operators valid for objects of that type. Class and type are essentially synonymous, though the two words have different roles in Python code.

concatenation

The combination of two sequences by creating a new sequence containing all of the items in the first sequence, followed by all of the items in the second sequence. For example (1, 2) + (3, 4) is (1, 2, 3, 4).

constructor

The __init__() method of a class. The constructor is passed the new object as its first argument (self) and is responsible for setting up the object. The constructor modifies self in place: constructors never return a value.

data attribute

An attribute which is not a method. As the name suggests, these are used to store data in an object.

encapsulation

The bundling up of attributes and methods into an object which can be dealt with as a single unit.

infix operator

A mathematical operator whose symbol is written between its operands. Examples include addition, subtraction, division and multiplication.

instance

An object of a particular class. a is an instance of MyClass means that a has class MyClass. We will return to this concept when we learn about inheritance.

instantiate

To create an instance of a class by calling its constructor.

method
instance method

A function defined within a class. If a is an instance of MyClass, and MyClass has a foo() method then a.foo() is equivalent to MyClass.foo(a). The first parameter of an instance method is always named self.

operands

The input values to an operator. For example the operands to + are the numbers being added (the summands), while the operands to exponentiation are the base and exponent.

pseudocode

A description of an algorithm given in the form of a computer program but without conforming to the rules of a particular programming language, and employing mathematical notation or plain text to express the algorithm in a human-readable form.

special method
magic method

A method which has special meaning in the Python language. Special method names are used to define operations on a class such as arithmetic operators, indexing, or the class constructor. Special methods have names starting and ending with a double underscore (__). See the Python documentation for a technical description. Special methods are sometimes informally called “magic methods”.

3.7. Exercises

Using the information on the book website obtain the skeleton code for these exercises. The skeleton code contains a polynomial package with a version of the Polynomial class.

Exercise 3.3

Turn the exercises repository into an installable Pip package. As with last chapter’s exercise, Pytest can’t test this so you’ll need to push to GitHub and check that the autograding tests pass there.

Exercise 3.4

Implement the following operations on the Polynomial class.

  1. Subtraction (__sub__() and __rsub__()).

  2. Multiplication by another polynomial, and by a scalar (__mul__() and __rmul__()).

  3. Exponentiation by a positive integer power (__pow__()). It may be useful to know that all integers are instances of numbers.Integral.

  4. Polynomial evaluation at a scalar value (__call__()).

Hint

A limitation of the provided implementation of Polynomial is that it doesn’t strip leading zeroes. This means that it doesn’t correctly identify that, for example, Polynomial((1, 0)) == Polynomial((1,)). You may find it convenient to remove this limitation by removing any leading zeroes in Polynomial.__init__(). If you do this, take care that printing the zero polynomial still works.

Note

Don’t forget to commit and push your changes, and make sure that the tests pass on GitHub!

Exercise 3.5

Define a dx() method on the Polynomial class which returns a new Polynomial which is the derivative of that Polynomial. Also define a function derivative in polynomials.py which takes a Polynomial and returns its derivative. Rather than duplicating code, you should implement the function by calling the method.

Exercise 3.6

Inside the exercise repository, create a new shape package containing a circle module.

  1. Create a Circle class whose constructor takes two user parameters, centre and radius. centre should be a length 2 sequence containing the two-dimensional coordinates of the centre, while radius is the radius of the circle.

  2. Add an import statement to shape/__init__.py so that the following code works:

    from shape import Circle
    
  3. Implement the __contains__() special method on the Circle class so that it returns True if a point (represented by a length 2 sequence of coordinates) lies inside the circle. For example, the following code should print True.

    from shape import Circle
    c = Circle((1., 0.), 2)
    print((0.5, 0.5) in c)
    

Footnotes