Tutorial#

We will here write some code to create and manipulate quadratic expressions. With sympy this is not necessary as all functionality required is available within sympy however this will be a good exercise in understanding how to build such functionality.

Problem

Consider the following quadratics:

\[\begin{split} f(x) = 5 x ^ 2 + 2 x - 7\\ g(x) = - 4 x ^ 2 - 3 x + 12\\ h(x) = f(x) + g(x) \end{split}\]

Without using sympy, obtain the roots for all the quadratics.

We will start by defining an object to represent a quadratic. This is called a class.

import math


class QuadraticExpression:
    """A class for a quadratic expression"""

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        self.discriminant = self.b ** 2 - 4 * self.a * self.c

    def get_roots(self):
        """
        Return the real valued roots of the quadratic expression

        Returns
        -------
        array
            The roots of the quadratic
        """
        if self.discriminant >= 0:
            x1 = -(self.b + math.sqrt(self.discriminant)) / (2 * self.a)
            x2 = -(self.b - math.sqrt(self.discriminant)) / (2 * self.a)
            return x1, x2
        return ()

    def __add__(self, other):
        """A magic method: let's us have addition between expressions"""
        return QuadraticExpression(self.a + other.a, self.b + other.b, self.c + other.c)

    def __repr__(self):
        """A magic method: changes the default way an instance is displayed"""
        return f"Quadratic expression: {self.a} x ^ 2 + {self.b} x + {self.c}"

Tip

Four functions were created with this class:

  • __init__: as this is surrounded by __ (two underscores) this is a magic function that is run when we create an instance of our class.

  • get_roots: this returns the two real valued roots if the discriminant is positive.

  • __add__: another magic function that is run when the + operator is used.

  • __repr__: another magic function that gives the string representation of the instance.

Let us now use this class to solve the specified problem. First we create instances the class that correspond to \(f\) and \(g\). This is using the __init__ function in the background.

f = QuadraticExpression(a=5, b=2, c=-7)
g = QuadraticExpression(a=-4, b=-3, c=12)

We can now take a look at both of these instances. This is using the __repr__ function in the background:

f
Quadratic expression: 5 x ^ 2 + 2 x + -7
g
Quadratic expression: -4 x ^ 2 + -3 x + 12

Now we are going to create \(h(x) = f(x) + g(x)\). This is using the __add__ function in the background:

h = f + g
h
Quadratic expression: 1 x ^ 2 + -1 x + 5

We can now iterate over our quadratics and find the roots. This is using the get_roots function in the background:

roots = [quadratic.get_roots() for quadratic in (f, g, h)]
roots
[(-1.4, 1.0), (1.3971808598447282, -2.1471808598447284), ()]

We see that \(f\) and \(g\) have real valued roots but \(h\) does not. We can check the value of the discriminant of \(h\):

h.discriminant
-19

We are going to now create a new class from QuadraticExpression where we replace the get_roots function with a new one that can handle imaginary roots and update the __add__ function to make sure we return an instance of the new class.

class QuadraticExpressionWithAllRoots(QuadraticExpression):
    """
    A class for a quadratic expression that can return imaginary roots

    The `get_roots` function returns two tuples of the form (re, im) where re is
    the real part and im is the imaginary part.
    """

    def get_roots(self):
        """
        Return the real valued roots of the quadratic expression

        Returns
        -------
        array
            The roots of the quadratic
        """
        if self.discriminant >= 0:
            x1 = -(self.b + math.sqrt(self.discriminant)) / (2 * self.a)
            x2 = -(self.b - math.sqrt(self.discriminant)) / (2 * self.a)
            return (x1, 0), (x2, 0)

        real_part = self.b / (2 * self.a)
        im1 = math.sqrt(-self.discriminant) / (2 * self.a)
        im2 = -math.sqrt(-self.discriminant) / (2 * self.a)
        return ((real_part, im1), (real_part, im2))

    def __add__(self, other):
        """A special method: let's us have addition between expressions"""
        return QuadraticExpressionWithAllRoots(
            self.a + other.a, self.b + other.b, self.c + other.c
        )

Now let us define our quadratics once again but using this new class:

f = QuadraticExpressionWithAllRoots(a=5, b=2, c=-7)
g = QuadraticExpressionWithAllRoots(a=-4, b=-3, c=12)
h = f + g
f
Quadratic expression: 5 x ^ 2 + 2 x + -7
g
Quadratic expression: -4 x ^ 2 + -3 x + 12
h
Quadratic expression: 1 x ^ 2 + -1 x + 5

Attention

We have not needed to redefine __init__, or __repr__ as the new class inherits these from QuadraticExpression due to this statement:

class QuadraticExpressionWithAllRoots(QuadraticExpression):

We can now get all the roots for both quadratics:

roots = [quadratic.get_roots() for quadratic in (f, g, h)]
roots
[((-1.4, 0), (1.0, 0)),
 ((1.3971808598447282, 0), (-2.1471808598447284, 0)),
 ((-0.5, 2.179449471770337), (-0.5, -2.179449471770337))]