Double underscore (dunder) methods, also known as special methods in Mojo🔥 are used to define how objects behave in specific situations. Mojo🔥 is a member of the Python family and it embraces Python's dunder method syntax and extends it in new ways. You can use dunder methods to customize the behavior of common operations such as +, -, *, **, ==, %, @ and others on custom data structures, initialize and delete objects, get string representation, enable indexing on objects and more. For example, you can use dunder methods to define __add__() to define element-wise addition operation using the + operator, on custom objects that represent vectors or tensors.
Mojo🔥 supports several dunder methods and in this blog post we'll dive deep into every single dunder method listed below with code examples. They are broadly categorized into the following types:
Defined by built-in Traits: len(obj), int(obj), print(obj)
Note: 1-5 are compiler dunder methods and 6 are dunder methods defined by built-in Traits that enable functions like len(), int() and print() to work on custom objects.
Dunder methods in Mojo🔥
Let’s say you want to implement the following equation in Mojo:
$z = x^3 + 3x - \frac{x}{5} + 0.3 + y^2$
It's easy to do that in Mojo if you’re working with scalar floating point variables:
Mojo
x = -1.0
y = 1.0
z = x**3 + 3*x - x/5. + 0.3 + y**2
print(z)
Output:
Output
-2.5
What if you wanted to extend this capability to custom objects that implement vectors? We’ll have to explicitly define the behavior of these three operations **, *, / for two cases:
When used between a scalar and a vector
When used between a vector and a vector
You can define these behaviors in the dunder methods __pow__(), __mul__() and __truediv__() with overloaded versions for scalar-vector and vector-vector operations. Through the rest of this blog post I'll walk you through creating an example Mojo struct (similar to a class in Python) that implements most of the dunder method supported by Mojo.
Note: This example was built with Mojo SDK v0.7. Future releases may add, change or remove supported dunder methods and/or Traits.
DunderArray: An example Mojo struct that demonstrates how to use Mojo dunder methods
The hero of this blog post is the DunderArray. We’ll implement a DunderArray struct which has the following qualities:
DunderArray is an array data structure that is parameterized by a type (Float64, Float32, Bool, etc.)
DunderArray implements all Mojo dunder methods shown in the figure above.
DunderArray also implements several dunder methods defined by built-in Traits
Using DunderArray you can do scalar-vector and vector-vector math operations like the example below, making it easy to implement mathematical equations. The complete example is on GitHub.
Mojo
x = DunderArray(np.sin(np.arange(0,6,0.1)))
y = DunderArray.rand(x.numel)
z = x**3 + 3*x - x/5. + 0.3 + y**2
plt.plot(z.to_numpy(),'r')
plt.show()
DunderArray struct has the following skeleton structure:
Mojo
struct DunderArray[dtype: DType = DType.float64](Stringable, Intable, CollectionElement, Sized):
var _ptr: DTypePointer[dtype]
var numel: Int
# Initialize, copy, move, delete
…
# Get and Set
…
# Unary operators
…
# Comparison operators.
…
# Normal, reversed and in-place operators
…
# Defined by built-in Traits
…
We’ll work our way through every dunder method defined in DunderArray struct, and for each dunder method in DunderArray we’ll discuss its usage and implementation.
1. Initialize, copy, move, delete
__init__()
The __init__() method is used to initialize a new object when it is created, set its initial attributes or perform additional setup. You can also overload it to allow different types of initializations.
You can have any number of overloads for __init__() to customize how you want to initialize your struct. The complete example on GitHub also includes an __init__() function that can load NumPy arrays into DunderArray.
Since my goal is to introduce you to dunder methods in Mojo, I’ve implemented these methods in DunderArray.
__copyinit__()
The __copyinit__() dunder method is invoked when the assignment operator = is used to make a copy of an object.
Usage:
You can assign a DunderArray variable a to a new variable b:
When a is assigned to b, __copyinit__() is invoked, which creates a copy of the underlying data in DunderArray and assigns it to other which in this case is variable b
You can learn more about copy constructors in the Mojo programming manual.
__moveinit__()
Similarly, when we want to move the value of a variable, its ownership is transferred from one variable to another using __moveinit__()
Usage:
Mojo
fn test_move():
var arr = DunderArray(2,4,6,8)
print(arr)
var arr_moved = arr^
print(arr_moved)
print(arr) #<-- ERROR: use of uninitialized value 'arr'
test_move()
Output:
Output
error: Expression [21]:6:11: use of uninitialized value 'arr'
print(arr) #<-- ERROR: use of uninitialized value 'arr'
^
You can learn more about move constructors in the Mojo programming manual.
Note: __copyinit__ and __moveinit__ are unique to Mojo🔥 and are not supported in Python. Mojo is Pythonic in syntax but also lets you incrementally adopt systems-programming features like strong type checking and memory management. You can also avoid implementing __init__(), __copyinit__() and __moveinit__() completely by using the @value decorator, as discussed in this doc page.
__del__()
The __del__ dunder method is used by Mojo to end a value’s lifetime.
Usage:
Mojo uses static compiler analysis to find when a value is last used and calls __del__ to perform the clean up.
Implementation:
In DunderArray this function simply frees the pointer. You can add any additional steps necessary to cleanup if needed.
In Mojo and in Python, the __getitem__() method is used to define how an object should behave when accessed with square brackets arr[index]. The __setitem__ method is used to specify the behavior when assigning a value to an item using square brackets arr[index] = value.
__getitem__()
Usage:
In DunderArray we can get an element from the array by using square bracket indexing:
Mojo
arr = DunderArray(1,2,3,4,5)
print(arr[3])
Output:
Output
4.0
Implementation:
We implement this logic in the __getitem__() method to load and return a value at a specific index.
You can also overload __getitem__() to implement slicing using the syntax arr[a:b] to retrieve values between a and b. I wrote an earlier blog post on how to implement NumPy style matrix slicing in Mojo🔥and you can find the code and explanation on how to do that in that blog post.
__setitem__()
DunderArray, lets you access specific elements in the array using square bracket indexing:
Unary arithmetic operators in Mojo are used to perform operations on a single operand, and they include + for positive, - for negation, and ~ for boolean inversion.
In the __pos__() and __neg__() dunder methods, we multiply each element of the DunderArray with +1.0 or -1.0. These functions assume the multiplication operation * has already been defined on DunderArray. We’ll discuss that in the arithmetic operations section.
Since DunderArray is parameterized on DType, we can also initialize a bool array. The __invert__() dunder method allows us to define the behavior of boolean inversion
To invert a DunderArray we have to first check that the array is of type bool. We do that using the compile time conditional _mlirtype_is_eq[Scalar[dtype], Bool]() which returns True if the array is boolean. If it is a boolean array, we use the elementwise function to invert the array in a fast vectorized way.
Mojo
fn __invert__(self) -> Self:
if _mlirtype_is_eq[Scalar[dtype], Bool]():
let new_array = Self(self.numel)
@parameter
fn wrapper[simd_width:Int,rank:Int=1](idx: StaticIntTuple[rank]):
new_array._ptr.simd_store[simd_width](idx[0], ~self._ptr.simd_load[simd_width](idx[0]))
elementwise[1, simdwidthof[dtype](), wrapper](self.numel)
return new_array
else:
print('Error: You can only invert Bool arrays')
return self
DunderArrays can be compared with each other using these comparison dunder methods. When comparing vectors, you have to define a metric on the vector that you wish to compare between vectors. In DunderArray struct we compute the euclidean norm and compare the norm values of two arrays.
5. Normal, reflected and in-place arithmetic operators
In Mojo, normal arithmetic operators perform the standard arithmetic operation between two operands, such as a + b for addition, a - b for subtraction, a * b for multiplication, and a / b for division. These are implemented with __add__(), __sub__(), __mul__() and __div__() dunder methods.
Reflected arithmetic operators are used when the left operand does not support the corresponding normal operator. For example, a float Scalar does not support addition with DunderArray, because it doesn’t know what a DunderArray is. The DunderArray struct can implement a reflected addition operator specified by the __radd__() that can perform the operation.
Finally, in-place arithmetic operators combine the current value of the left operand with the right operand and assign the result to the left operand. For instance, a += b is an in-place addition, and it is equivalent to a = a + b. These operators are specified by methods such as __iadd__(), __isub__(), __imul__(), and __itruediv__().
__add__(), __radd__(), __iadd__()
You can add a scalar to a DunderArray or add two DunderArrays together.
Usage:
Mojo
arr1 = DunderArray.rand(5)
arr2 = DunderArray(100,100,100,100,100)
# Normal and in-place
print(arr1+100.0)
print(arr1+arr2)
# Reflected
print(100.0+arr1)
#In-place
arr1+=100.0
print(arr1)
In DunderArray we use two helper functions to calculate elementwise operations between floating point scalars and DunderArray (_elemwise_scalar_math) and DunderArray and DunderArray_elemwise_array_math
You can also define a special behavior for the @ operators, which is typically reserved for matrix multiplication. Since we're working with vectors only, and * already defines elementwise vector multiplications, we'll use the @ operators to calculate dot products between DunderArrays:
Usage:
Mojo
arr1 = DunderArray.rand(5)
arr2 = DunderArray.rand(5)
# Normal and in-place
print(arr1@arr2)
#Verify
print((arr1*arr2)._reduce_sum())
#In-place
arr1@=arr2
print(arr1)
Floor division is similar to true division operation, bit it rounds down the result to the nearest integer, yielding the largest integer that is less than or equal to the exact result. DunderArray implements the //
DunderArray implements the modulus operation using the % operator, which returns the remainder of the elementwise division of two arrays or between one array and a scalar.
DunderArrays can be left shifted (<< n) which performs elementwise shifts the bits which is effectively multiplying it by $2^n$. Right shift (>> n) performs elementwise bits to the right, akin to dividing the number by $2^n$
DunderArray can also be initialized as a boolean array by using the dtype=DType.bool. With a boolean DunderArray, you can perform standard boolean operations such as and, or, and xor described below.
This means DunderArray must implement __len__()and __int__() which lets you can call the len() and int() functions on DunderArray objects. For the purpose of illustration, we return the same values for both functions.
DunderArray also conforms to the Stringable trait, so we must implement the __str__() that returns a String that can be printed using the print() function. You've seen this in action throughout this blog post, and here you'll can see how it's implemented.
Usage:
Mojo
arr = DunderArray(1,2,3,4,5)
print(arr)
Output:
Output
[1.0 2.0 3.0 4.0 5.0]
Length:5, DType:float64
Implementation:
Mojo
fn __str__(self) -> String:
var printStr:String = "["
var prec:Int=4
for i in range(self.numel):
var val = self[i]
@parameter
if _mlirtype_is_eq[Scalar[dtype], Float64]():
var s: String = ""
let int_str: String
int_str = String(math.trunc(val).cast[DType.int32]())
if val < 0.0:
val = -val
let float_str: String
if math.mod(val,1)==0:
float_str = "0"
else:
float_str = String(math.mod(val,1))[2:prec+2]
s = int_str+"."+float_str
if i==0:
printStr+=s
else:
printStr+=" "+s
else:
if i==0:
printStr+=str(val)
else:
printStr+=" "+str(val)
printStr+="]\n"
printStr+="Length:"+str(self.numel)+","+" DType:"+str(dtype)
return printStr
Conclusion
That's it folks, I hope you enjoyed this deep dive into all the dunder methods. To sum up, using dunder methods in Mojo🔥 provides a way to customize struct object behaviors. On GitHub you'll find both a Jupyter notebook that shows you how to use DunderArray and a Mojo file which implements the DunderArray.
If you're new to Mojo🔥 here are some additional getting started resources:
Shashank is an engineer, educator and doodler. He writes and talks about machine learning, specialized machine learning hardware (AI Accelerators) and AI Infrastructure in the cloud. He previously worked at Meta, AWS, NVIDIA, MathWorks (MATLAB) and Oracle in developer relations and marketing, product management, and software development roles and hold an M.S. in electrical engineering.