Protocols vs. Traits
Python has a concept called protocols, sometimes referred to as special methods, or "dunder methods" implemented on
built-in types in the standard library.
For example, the __str__
method is used to implement the str()
function, which returns the string representation of an object.
Th __repr__
method is used to implement the repr()
function, which returns a string containing a printable representation of an object.
Python: Protocols
In Python, we start by defining a simple Person
class that has a name and an age attribute.
To make the output of the print
statement more interesting, we implement the following __str__
and __repr__
methods
that are translated to the str()
and repr()
functions respectively.
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
if age > 0 and isinstance(age, int):
self.age = age
else:
raise ValueError("Age must be a positive integer")
def __str__(self) -> str:
return f"{self.name} is {self.age} years old"
def __repr__(self) -> str:
return f"Person: {self.name}, {self.age}"
One limitation of Python's type system that's worth noting is that it treats all integers as int
types, even if they
are unsigned. In this case, the age of a person should be a positive integer, so we need to check for this by using
an if
statement in the class constructor defined in the __init__
block. Rust's type system, as we'll see, is
more powerful, while also being stricter than Python's.
We can now create a Person
object via a function and print it to the console by running the code via main.py
.
def run1() -> None:
person = Person("Megan", 28)
print(person)
print(repr(person))
"""
Megan is 28 years old
Person: Megan, 28
"""
When we print the person
object, the __str__
method is called, and when we print the repr
object,
the __repr__
method is called, thus producing slightly different outputs depending on what we want to display.
Generally, repr()
is used for debugging a stack trace, and str()
is used for displaying something to the user.
Rust: Traits
In Rust, we start by defining a Person
struct with a name and an age attribute, in a similar way to the Python example.
struct Person {
name: String,
age: u8,
}
Unlike a Python class which always provides __init__
, Rust doesn't provide constructors on structs, so we
need to define an implementation block (shown below) for the Person
struct via the impl
keyword.
As noted earlier, Rust allows us to declare the age
variable as an unsigned integer, which is more
appropriate for this use case, eliminating the need to check for positive integers in the constructor.
This makes the code more concise and easier to read in this case.
impl Person {
fn new(name: &str, age: u8) -> Self {
Self {
name: name.to_string(),
age,
}
}
}
Two things stand out in the impl
block defined. We provide an argument &str
, which represents a string slice,
and we use the to_string()
method to convert the string slice to a String
type.
Because Rust is a statically typed language, it needs to know the type and allocation of all variables at compile time.
When we input a person's name during initialization, we don't know how long the name will be. However, arguments to
functions and methods in Rust are passed by reference, so we'd typically use a string slice to represent the name. The
compiler keeps a track of all this, so if you forget to call the to_string()
method, you'll get a nice compiler error!
Rust has its own versions of Python's __str__
and __repr__
methods, but they're called Display
and Debug
traits.
A trait is similar to an interface in other languages, and vaguely similar to a protocol in Python, because it describes
an object's behavior.
impl fmt::Display for Person {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} is {} years old", self.name, self.age)
}
}
impl fmt::Debug for Person {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Person: {}, {}", self.name, self.age)
}
}
Display
and Debug
traits are typically not automatically defined on every object type in Rust, because its
strict type system needs to know upfront what the user wants to do with the object's types prior to displaying them.
With these bits in place, we can now create a Person
object via a function and print it to the console by running the code via main.rs
.
fn run1() {
let p = Person::new("Megan", 28);
println!("{}", p);
println!("{:?}", p);
/*
Megan is 28 years old
Person: Megan, 28
*/
}
Note that in Rust, for printing Debug
traits, we use the {:?}
format specifier, whereas for Display
traits, we can just use {}
.
The above output is identical to the Python output!
Takeaways
- Rust's type system is stricter and more powerful than Python's, allowing us to define unsigned integers and other types that are not available in Python's standard library.
- Python is object-oriented, so it uses classes in many cases to keep related data and methods together
- Rust isn't an object-oriented language and doesn't use classes, but it does have the concept of traits and implementations
- In Rust, constructors aren't defined on custom structs, so we need to define the struct's constructor via an implementation
block using the
impl
keyword.