Make a New Primitive Type

PythonPythonBeginner
Practice Now

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn how to create a new primitive type in Python and implement essential methods for it. You'll also gain an understanding of Python's object protocol. In most Python programs, built - in primitive types like int, float, and str are used to represent data. However, Python enables you to create custom types, as seen in modules like decimal and fractions in the standard library.

In this lab, you will create a new primitive type named MutInt (Mutable Integer). Unlike Python's immutable integers, MutInt can be modified after creation. This exercise will showcase the fundamental principles required to create a fully functional primitive type in Python.

This is a Guided Lab, which provides step-by-step instructions to help you learn and practice. Follow the instructions carefully to complete each step and gain hands-on experience. Historical data shows that this is a beginner level lab with a 90% completion rate. It has received a 97% positive review rate from learners.

Creating a Basic MutInt Class

Let's start by creating a basic class for our Mutable Integer type. In programming, a class is like a blueprint for creating objects. In this step, we'll create the foundation of our new primitive type. A primitive type is a basic data type provided by a programming language, and here we're going to build our own custom one.

  1. Open the WebIDE and navigate to the /home/labex/project directory. The WebIDE is an integrated development environment where you can write, edit, and run your code. Navigating to this directory ensures that all your files are organized in one place and can interact with each other properly.

  2. Open the mutint.py file that was created for you in the setup step. This file will be the home for our MutInt class definition.

  3. Add the following code to define a basic MutInt class:

## mutint.py

class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

The __slots__ attribute is used to define the attributes that this class can have. Attributes are like variables that belong to an object of the class. By using __slots__, we tell Python to use a more memory - efficient way to store attributes. In this case, our MutInt class will only have a single attribute called value. This means that each object of the MutInt class will only be able to hold one piece of data, which is the integer value.

The __init__ method is the constructor for our class. A constructor is a special method that gets called when an object of the class is created. It takes a value parameter and stores it in the value attribute of the instance. An instance is an individual object created from the class blueprint.

Let's test our class by creating a Python script to use it:

  1. Create a new file called test_mutint.py in the same directory:
## test_mutint.py

from mutint import MutInt

## Create a MutInt object
a = MutInt(3)
print(f"Created MutInt with value: {a.value}")

## Modify the value (demonstrating mutability)
a.value = 42
print(f"Modified value to: {a.value}")

## Try adding (this will fail)
try:
    result = a + 10
    print(f"Result of a + 10: {result}")
except TypeError as e:
    print(f"Error when adding: {e}")

In this test script, we first import the MutInt class from the mutint.py file. Then we create an object of the MutInt class with an initial value of 3. We print the initial value, then modify it to 42 and print the new value. Finally, we try to add 10 to the MutInt object, which will result in an error because our class doesn't support the addition operation yet.

  1. Run the test script by executing the following command in the terminal:
python3 /home/labex/project/test_mutint.py

The terminal is a command - line interface where you can run various commands to interact with your system and your code. Running this command will execute the test_mutint.py script.

You should see output similar to this:

Created MutInt with value: 3
Modified value to: 42
Error when adding: unsupported operand type(s) for +: 'MutInt' and 'int'

Our MutInt class successfully stores and updates a value. However, it has several limitations:

  • It doesn't display nicely when printed
  • It doesn't support mathematical operations like addition
  • It doesn't support comparisons
  • It doesn't support type conversions

In the next steps, we'll address these limitations one by one to make our MutInt class behave more like a true primitive type.

✨ Check Solution and Practice

Improving String Representation

When you print a MutInt object in Python, you'll see an output like <__main__.MutInt object at 0x...>. This output isn't very useful because it doesn't tell you the actual value of the MutInt object. To make it easier to understand what the object represents, we're going to implement special methods for string representation.

  1. Open mutint.py in the WebIDE and update it with the following code:
## mutint.py

class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

    def __str__(self):
        """Return a string representation for printing."""
        return str(self.value)

    def __repr__(self):
        """Return a developer-friendly string representation."""
        return f'MutInt({self.value!r})'

    def __format__(self, fmt):
        """Support string formatting with format specifications."""
        return format(self.value, fmt)

We've added three important methods to the MutInt class:

  • __str__(): This method is called when you use the str() function on the object or when you print the object directly. It should return a human-readable string.
  • __repr__(): This method provides the "official" string representation of the object. It's mainly used for debugging and should ideally return a string that, if passed to the eval() function, would recreate the object.
  • __format__(): This method allows you to use Python's string formatting system with your MutInt objects. You can use format specifications like padding and number formatting.
  1. Create a new test file called test_string_repr.py to test these new methods:
## test_string_repr.py

from mutint import MutInt

## Create a MutInt object
a = MutInt(3)

## Test string representation
print(f"str(a): {str(a)}")
print(f"repr(a): {repr(a)}")

## Test direct printing
print(f"Print a: {a}")

## Test string formatting
print(f"Formatted with padding: '{a:*^10}'")
print(f"Formatted as decimal: '{a:d}'")

## Test mutability
a.value = 42
print(f"After changing value, repr(a): {repr(a)}")

In this test file, we first import the MutInt class. Then we create a MutInt object with the value 3. We test the __str__() and __repr__() methods by using the str() and repr() functions. We also test direct printing, string formatting, and the mutability of the MutInt object.

  1. Run the test script:
python3 /home/labex/project/test_string_repr.py

When you run this command, Python will execute the test_string_repr.py script. You should see output similar to this:

str(a): 3
repr(a): MutInt(3)
Print a: 3
Formatted with padding: '****3*****'
Formatted as decimal: '3'
After changing value, repr(a): MutInt(42)

Now our MutInt objects display nicely. The string representation shows the underlying value, and we can use string formatting just like with regular integers.

The difference between __str__() and __repr__() is that __str__() is meant to produce a human-friendly output, while __repr__() should ideally produce a string that, when passed to eval(), would recreate the object. This is why we included the class name in the __repr__() method.

The __format__() method allows our object to work with Python's formatting system, so we can use format specifications like padding and number formatting.

✨ Check Solution and Practice

Adding Mathematical Operations

Currently, our MutInt class doesn't support mathematical operations like addition. In Python, to enable such operations for a custom class, we need to implement special methods. These special methods are also known as "magic methods" or "dunder methods" because they are surrounded by double underscores. Let's add the functionality for addition by implementing the relevant special methods for arithmetic operations.

  1. Open mutint.py in the WebIDE and update it with the following code:
## mutint.py

class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

    def __str__(self):
        """Return a string representation for printing."""
        return str(self.value)

    def __repr__(self):
        """Return a developer-friendly string representation."""
        return f'MutInt({self.value!r})'

    def __format__(self, fmt):
        """Support string formatting with format specifications."""
        return format(self.value, fmt)

    def __add__(self, other):
        """Handle addition: self + other."""
        if isinstance(other, MutInt):
            return MutInt(self.value + other.value)
        elif isinstance(other, int):
            return MutInt(self.value + other)
        else:
            return NotImplemented

    def __radd__(self, other):
        """Handle reversed addition: other + self."""
        ## For commutative operations like +, we can reuse __add__
        return self.__add__(other)

    def __iadd__(self, other):
        """Handle in-place addition: self += other."""
        if isinstance(other, MutInt):
            self.value += other.value
            return self
        elif isinstance(other, int):
            self.value += other
            return self
        else:
            return NotImplemented

We've added three new methods to the MutInt class:

  • __add__(): This method is called when the + operator is used with our MutInt object on the left side. Inside this method, we first check if the other operand is an instance of MutInt or an int. If it is, we perform the addition and return a new MutInt object with the result. If the other operand is something else, we return NotImplemented. This tells Python to try other methods or raise a TypeError.
  • __radd__(): This method is called when the + operator is used with our MutInt object on the right side. Since addition is a commutative operation (i.e., a + b is the same as b + a), we can simply reuse the __add__ method.
  • __iadd__(): This method is called when the += operator is used on our MutInt object. Instead of creating a new object, it modifies the existing MutInt object and returns it.
  1. Create a new test file called test_math_ops.py to test these new methods:
## test_math_ops.py

from mutint import MutInt

## Create MutInt objects
a = MutInt(3)
b = MutInt(5)

## Test regular addition
c = a + b
print(f"a + b = {c}")

## Test addition with int
d = a + 10
print(f"a + 10 = {d}")

## Test reversed addition
e = 7 + a
print(f"7 + a = {e}")

## Test in-place addition
print(f"Before a += 5: a = {a}")
a += 5
print(f"After a += 5: a = {a}")

## Test in-place addition with reference sharing
f = a  ## f and a point to the same object
print(f"Before a += 10: a = {a}, f = {f}")
a += 10
print(f"After a += 10: a = {a}, f = {f}")

## Test unsupported operation
try:
    result = a + 3.5  ## Adding a float is not supported
    print(f"a + 3.5 = {result}")
except TypeError as e:
    print(f"Error when adding float: {e}")

In this test file, we first import the MutInt class. Then we create some MutInt objects and perform different types of addition operations. We also test the in-place addition and the case where an unsupported operation (adding a float) is attempted.

  1. Run the test script:
python3 /home/labex/project/test_math_ops.py

You should see output similar to this:

a + b = MutInt(8)
a + 10 = MutInt(13)
7 + a = MutInt(10)
Before a += 5: a = MutInt(3)
After a += 5: a = MutInt(8)
Before a += 10: a = MutInt(8), f = MutInt(8)
After a += 10: a = MutInt(18), f = MutInt(18)
Error when adding float: unsupported operand type(s) for +: 'MutInt' and 'float'

Now our MutInt class supports basic addition operations. Notice that when we used +=, both a and f were updated. This shows that a += 10 modified the existing object rather than creating a new one.

This behavior with mutable objects is similar to Python's built-in mutable types like lists. For example:

a = [1, 2, 3]
b = a
a += [4, 5]  ## Both a and b are updated

In contrast, for immutable types like tuples, += creates a new object:

c = (1, 2, 3)
d = c
c += (4, 5)  ## c is a new object, d still points to the old one
✨ Check Solution and Practice

Implementing Comparison Operations

Currently, our MutInt objects cannot be compared with each other or with regular integers. In Python, comparison operations like ==, <, <=, >, >= are very useful when working with objects. They allow us to determine relationships between different objects, which is crucial in many programming scenarios such as sorting, filtering, and conditional statements. So, let's add comparison functionality to our MutInt class by implementing the special methods for comparison operations.

  1. Open mutint.py in the WebIDE and update it with the following code:
## mutint.py

from functools import total_ordering

@total_ordering
class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

    def __str__(self):
        """Return a string representation for printing."""
        return str(self.value)

    def __repr__(self):
        """Return a developer-friendly string representation."""
        return f'MutInt({self.value!r})'

    def __format__(self, fmt):
        """Support string formatting with format specifications."""
        return format(self.value, fmt)

    def __add__(self, other):
        """Handle addition: self + other."""
        if isinstance(other, MutInt):
            return MutInt(self.value + other.value)
        elif isinstance(other, int):
            return MutInt(self.value + other)
        else:
            return NotImplemented

    def __radd__(self, other):
        """Handle reversed addition: other + self."""
        return self.__add__(other)

    def __iadd__(self, other):
        """Handle in-place addition: self += other."""
        if isinstance(other, MutInt):
            self.value += other.value
            return self
        elif isinstance(other, int):
            self.value += other
            return self
        else:
            return NotImplemented

    def __eq__(self, other):
        """Handle equality comparison: self == other."""
        if isinstance(other, MutInt):
            return self.value == other.value
        elif isinstance(other, int):
            return self.value == other
        else:
            return NotImplemented

    def __lt__(self, other):
        """Handle less-than comparison: self < other."""
        if isinstance(other, MutInt):
            return self.value < other.value
        elif isinstance(other, int):
            return self.value < other
        else:
            return NotImplemented

We've added several key improvements:

  1. Import and use the @total_ordering decorator from the functools module. The @total_ordering decorator is a powerful tool in Python. It helps us save a lot of time and effort when implementing comparison methods for a class. Instead of manually defining all six comparison methods (__eq__, __ne__, __lt__, __le__, __gt__, __ge__), we only need to define __eq__ and one other comparison method (in our case, __lt__). The decorator will then automatically generate the remaining four comparison methods for us.

  2. Add the __eq__() method to handle equality comparisons (==). This method is used to check if two MutInt objects or a MutInt object and an integer have the same value.

  3. Add the __lt__() method to handle less-than comparisons (<). This method is used to determine if one MutInt object or a MutInt object compared to an integer has a smaller value.

  4. Create a new test file called test_comparisons.py to test these new methods:

## test_comparisons.py

from mutint import MutInt

## Create MutInt objects
a = MutInt(3)
b = MutInt(3)
c = MutInt(5)

## Test equality
print(f"a == b: {a == b}")  ## Should be True (same value)
print(f"a == c: {a == c}")  ## Should be False (different values)
print(f"a == 3: {a == 3}")  ## Should be True (comparing with int)
print(f"a == 5: {a == 5}")  ## Should be False (different values)

## Test less than
print(f"a < c: {a < c}")    ## Should be True (3 < 5)
print(f"c < a: {c < a}")    ## Should be False (5 is not < 3)
print(f"a < 4: {a < 4}")    ## Should be True (3 < 4)

## Test other comparisons (provided by @total_ordering)
print(f"a <= b: {a <= b}")  ## Should be True (3 <= 3)
print(f"a > c: {a > c}")    ## Should be False (3 is not > 5)
print(f"c >= a: {c >= a}")  ## Should be True (5 >= 3)

## Test with a different type
print(f"a == '3': {a == '3'}")  ## Should be False (different types)

In this test file, we create several MutInt objects and perform different comparison operations on them. We also compare MutInt objects with regular integers and a different type (a string in this case). By running these tests, we can verify that our comparison methods work as expected.

  1. Run the test script:
python3 /home/labex/project/test_comparisons.py

You should see output similar to this:

a == b: True
a == c: False
a == 3: True
a == 5: False
a < c: True
c < a: False
a < 4: True
a <= b: True
a > c: False
c >= a: True
a == '3': False

Now our MutInt class supports all comparison operations.

The @total_ordering decorator is particularly useful because it saves us from having to implement all six comparison methods manually. By providing just __eq__ and __lt__, Python can derive the other four comparison methods automatically.

When implementing custom classes, it's generally a good practice to make them work with both objects of the same type and with built-in types where it makes sense. That's why our comparison methods handle both MutInt objects and regular integers. This way, our MutInt class can be used more flexibly in different programming scenarios.

✨ Check Solution and Practice

Adding Type Conversions

Our MutInt class currently supports addition and comparison operations. However, it doesn't work with Python's built - in conversion functions such as int() and float(). These conversion functions are very useful in Python. For example, when you want to convert a value to an integer or a floating - point number for different calculations or operations, you rely on these functions. So, let's add the capabilities to our MutInt class to work with them.

  1. Open mutint.py in the WebIDE and update it with the following code:
## mutint.py

from functools import total_ordering

@total_ordering
class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

    def __str__(self):
        """Return a string representation for printing."""
        return str(self.value)

    def __repr__(self):
        """Return a developer - friendly string representation."""
        return f'MutInt({self.value!r})'

    def __format__(self, fmt):
        """Support string formatting with format specifications."""
        return format(self.value, fmt)

    def __add__(self, other):
        """Handle addition: self + other."""
        if isinstance(other, MutInt):
            return MutInt(self.value + other.value)
        elif isinstance(other, int):
            return MutInt(self.value + other)
        else:
            return NotImplemented

    def __radd__(self, other):
        """Handle reversed addition: other + self."""
        return self.__add__(other)

    def __iadd__(self, other):
        """Handle in - place addition: self += other."""
        if isinstance(other, MutInt):
            self.value += other.value
            return self
        elif isinstance(other, int):
            self.value += other
            return self
        else:
            return NotImplemented

    def __eq__(self, other):
        """Handle equality comparison: self == other."""
        if isinstance(other, MutInt):
            return self.value == other.value
        elif isinstance(other, int):
            return self.value == other
        else:
            return NotImplemented

    def __lt__(self, other):
        """Handle less - than comparison: self < other."""
        if isinstance(other, MutInt):
            return self.value < other.value
        elif isinstance(other, int):
            return self.value < other
        else:
            return NotImplemented

    def __int__(self):
        """Convert to int."""
        return self.value

    def __float__(self):
        """Convert to float."""
        return float(self.value)

    __index__ = __int__  ## Support array indexing and other operations requiring an index

    def __lshift__(self, other):
        """Handle left shift: self << other."""
        if isinstance(other, MutInt):
            return MutInt(self.value << other.value)
        elif isinstance(other, int):
            return MutInt(self.value << other)
        else:
            return NotImplemented

    def __rlshift__(self, other):
        """Handle reversed left shift: other << self."""
        if isinstance(other, int):
            return MutInt(other << self.value)
        else:
            return NotImplemented

We've added three new methods to the MutInt class:

  1. __int__(): This method is called when you use the int() function on an object of our MutInt class. For example, if you have a MutInt object a, and you write int(a), Python will call the __int__() method of the a object.
  2. __float__(): Similarly, this method is called when you use the float() function on our MutInt object.
  3. __index__(): This method is used for operations that specifically require an integer index. For instance, when you want to access an element in a list using an index, or perform bit - length operations, Python needs an integer index.
  4. __lshift__(): This method handles left shift operations when the MutInt object is on the left side of the << operator.
  5. __rlshift__(): This method handles left shift operations when the MutInt object is on the right side of the << operator.

The __index__ method is crucial for operations that demand an integer index, like list indexing, slicing, and bit - length operations. In our simple implementation, we set it to be the same as __int__ because our MutInt object's value can be directly used as an integer index.

The __lshift__ and __rlshift__ methods are essential for supporting bitwise left shift operations. They allow our MutInt objects to participate in bitwise operations, which is a common requirement for integer-like types.

  1. Create a new test file called test_conversions.py to test these new methods:
## test_conversions.py

from mutint import MutInt

## Create a MutInt object
a = MutInt(3)

## Test conversions
print(f"int(a): {int(a)}")
print(f"float(a): {float(a)}")

## Test using as an index
names = ['Dave', 'Guido', 'Paula', 'Thomas', 'Lewis']
print(f"names[a]: {names[a]}")

## Test using in bit operations (requires __index__)
print(f"1 << a: {1 << a}")  ## Shift left by 3

## Test hex/oct/bin functions (requires __index__)
print(f"hex(a): {hex(a)}")
print(f"oct(a): {oct(a)}")
print(f"bin(a): {bin(a)}")

## Modify and test again
a.value = 4
print(f"\nAfter changing value to 4:")
print(f"int(a): {int(a)}")
print(f"names[a]: {names[a]}")
  1. Run the test script:
python3 /home/labex/project/test_conversions.py

You should see output similar to this:

int(a): 3
float(a): 3.0
names[a]: Thomas
1 << a: 8
hex(a): 0x3
oct(a): 0o3
bin(a): 0b11

After changing value to 4:
int(a): 4
names[a]: Lewis

Now our MutInt class can be converted to standard Python types and used in operations that require an integer index.

The __index__ method is particularly important. It was introduced in Python to allow objects to be used in situations where an integer index is required, such as list indexing, bitwise operations, and various functions like hex(), oct(), and bin().

With these additions, our MutInt class is now a fairly complete primitive type. It can be used in most contexts where a regular integer would be used, with the added benefit of being mutable.

Complete MutInt Implementation

Here's our complete MutInt implementation with all the features we've added:

## mutint.py

from functools import total_ordering

@total_ordering
class MutInt:
    """
    A mutable integer class that allows its value to be modified after creation.
    """
    __slots__ = ['value']

    def __init__(self, value):
        """Initialize with an integer value."""
        self.value = value

    def __str__(self):
        """Return a string representation for printing."""
        return str(self.value)

    def __repr__(self):
        """Return a developer - friendly string representation."""
        return f'MutInt({self.value!r})'

    def __format__(self, fmt):
        """Support string formatting with format specifications."""
        return format(self.value, fmt)

    def __add__(self, other):
        """Handle addition: self + other."""
        if isinstance(other, MutInt):
            return MutInt(self.value + other.value)
        elif isinstance(other, int):
            return MutInt(self.value + other)
        else:
            return NotImplemented

    def __radd__(self, other):
        """Handle reversed addition: other + self."""
        return self.__add__(other)

    def __iadd__(self, other):
        """Handle in - place addition: self += other."""
        if isinstance(other, MutInt):
            self.value += other.value
            return self
        elif isinstance(other, int):
            self.value += other
            return self
        else:
            return NotImplemented

    def __eq__(self, other):
        """Handle equality comparison: self == other."""
        if isinstance(other, MutInt):
            return self.value == other.value
        elif isinstance(other, int):
            return self.value == other
        else:
            return NotImplemented

    def __lt__(self, other):
        """Handle less - than comparison: self < other."""
        if isinstance(other, MutInt):
            return self.value < other.value
        elif isinstance(other, int):
            return self.value < other
        else:
            return NotImplemented

    def __int__(self):
        """Convert to int."""
        return self.value

    def __float__(self):
        """Convert to float."""
        return float(self.value)

    __index__ = __int__  ## Support array indexing and other operations requiring an index

    def __lshift__(self, other):
        """Handle left shift: self << other."""
        if isinstance(other, MutInt):
            return MutInt(self.value << other.value)
        elif isinstance(other, int):
            return MutInt(self.value << other)
        else:
            return NotImplemented

    def __rlshift__(self, other):
        """Handle reversed left shift: other << self."""
        if isinstance(other, int):
            return MutInt(other << self.value)
        else:
            return NotImplemented

This implementation covers the key aspects of creating a new primitive type in Python. To make it even more complete, you could implement additional methods for other operations like subtraction, multiplication, division, etc.

✨ Check Solution and Practice

Summary

In this lab, you have learned how to create your own primitive type in Python. Specifically, you've mastered creating a mutable integer class similar to built - in types, implementing special methods for object display, adding support for mathematical and comparison operations, and enabling type conversions for various Python contexts.

These concepts are essential for understanding Python's object model and can be used to create custom types that integrate well with built - in operations. To further your knowledge, consider implementing more mathematical operations, adding support for other built - in functions, and exploring complex types like custom collections. Custom types in Python are a powerful tool to extend the language for specific needs.