Rust's object system
9 April 2012
On the rust-dev
mailing list, someone pointed out another
“BitC retrospective” post by Jonathon Shapiro concerning typeclasses.
The Rust object system provides interesting solutions to some of the
problems he raises. We also manage to combine traditional
class-oriented OOP with Haskell’s type classes in a way that feels
seamless to me. I thought I would describe the object system as I see
it in a post. However, it turns out that this will take me far too
long to fit into a single blog post, so I’m going to do a series.
This first one just describes the basics.
One caveat: I think that these techniques are novel, at least in some parts. However, I am not well-versed in the Haskell literature and it’s possible that the techniques we aim to implement have been explored already. If so, I’d appreciate it if someone would point me in the right direction! There are some links in his post that I haven’t read, for example, but I will definitely put them on my reading list.
EDIT: It’s a bit unclear what I precisely think is novel. In fact, when I wrote the previous paragraph, I was referring to our proposed technique for enforcing instance coherence. However, I didn’t even describe this problem in this post, because I realized there was a lot of background to cover. So, to be clear, I don’t think that the basics in this post are terribly novel—with the exception of our use of the same interfaces to unify Haskell-style type-classes (or C++ concepts, if you prefer) with OOP-style existential (sub)typing. That particular part works out quite well, I think.
The building block: ifaces
The fundamental building block of Rust’s OOP system is the iface
(interface). As in Java and other languages, an iface is just a set
of methods without implementations. Let’s use the example of a
hashable
value, which might be suitable for use as the key in a
hashtable:
iface hashable {
fn hash() -> uint;
fn eq(t: self) -> bool;
}
This interface provides two methods. The first, hash()
, computes a
hash of the value and the second compares for equality. You see that
an iface can use the special type self
. The type self
means “the
same type as the receiver”. A later post will demonstrate that this
type—while extremely useful!—introduces some complications.
Classes
Classes are like a pared down version of the classes you will find in other languages. As in C++, they have fields, methods, constructors and an optional destructor. However, they do not inherit from one another (we will see how to do polymorphism in a bit). You can define a class like so:
class a_class {
let x: int, y: uint;
new(x: int, y: uint) {
self.x = x;
self.y = y;
}
fn get_x() -> int { self.x }
}
The precise syntax will probably change (I am not fond of the
definition of constructors, in particular), but the basic idea will
remain the same: a class combines a set of fields with various
methods. Members can be defined as private or public with the usual,
C++- or Java-like definition. Fields can be immutable (the default)
or mutable (let mut x: int
).
Polymorphism using classes and ifaces
There is no subtyping between classes. However, sometimes you would like to have a routine that operates on multiple types. The canonical example is to have an interface for “drawable” things like:
iface draw {
fn draw(gfx: graphics_context);
}
Along with various drawable shapes like:
class square { fn draw(gfx: graphics_context) { ... } }
class circle { fn draw(gfx: graphics_context) { ... } }
...
Rust then offers you two ways to work with these drawable things. The first, interface types, is more like C++ or Java. The second, bounded type parameters, is more like Haskell’s type classes. As we will see, each technique is useful for different scenarios.
Interface types
As in Java, an interface like draw
also has a corresponding type
(simply written as draw
). In fact, it has a family of types
(draw@
, draw~
, draw&
, and draw
) just as with function
pointers, but for now there is no need to get into the full details.
The type draw
will suffice.
The type draw
means “some value which implements the drawable
interface”. We can use the draw
type to write a function which
takes a vector of drawable things and draws them all:
fn draw_all(gfx: graphics_context, drawables: [draw]) {
for drawables.each {|drawable|
drawable.draw(gfx)
}
}
This looks pretty close to Java or C++. However, what happens at
runtime is somewhat different in some pretty important ways. For one
thing, the draw
type in Rust is represented as the pair of a pointer
to the instance data along with a vtable. Invoking the draw
method, therefore, is simply a matter of extracting the function
pointer from the vtable and invoking it with the instance data as the
(implicit) first argument.
This representation is somewhat different from Java or C++, both of which would have a single pointer to the object and would embed the vtable in the object itself. There are a variety of reasons that we take a different approach which I will cover later.
The reason I am talking about how draw
instances are represented at
runtime is that it is not the same as the way that a @circle
instance (for example) is represented. The type @circle
is just a
pointer to the a block of memory containing the fields for the class
circle. There is only a single pointer and there is no vtable. So we
cannot simply interpret the type @circle
as a draw
instance
without doing some conversion.
In Rust, this conversion is accomplished by casting the @circle
instance to the draw
type. So, an example of using the draw_all
method might look like:
fn draw_a_square_and_a_circle(gfx: graphics_context) {
let s = @square(...);
let c = @circle(...);
let objs = [s as draw, c as draw];
draw_all(gfx, objs);
}
Here you can see that to construct the vector of drawables, we first
casted s
and c
to the type draw
. This cast constructs the pair
of the s
and c
pointers along with the appropriate vtable (in the
first case, one for square
, in the second case, one for circle
).
Why is it designed this way?
There are a variety of reasons that we took a different approach from that used in Java or C++. First, we wished to preserve the nice quality of C++ that all virtual calls are implemented using simple vtables: this is an efficient technique with reliable performance. In Java, in contrast, the precise implementation of interface calls can vary. Of course the JIT is able to generally produce efficient code (typically using PICs or similar things) but we want to be able to statically compile Rust without the need for just-in-time techniques.
However, we also did not want to require that classes be pre-declared as “implementing” a particular interface (or, in the case of C++, extending the given abstract class). In C++, the subtyping relationship is used to guide the construction and layout of the vtables (and, in some cases, multiple such vtables may be needed, meaning that there is no unique pointer to the object data itself). Without having that pre-declared relationship, we cannot pre-compute the vtable(s) for an object in advance.
Therefore, we instead wait and lazilly construct the vtable at the
point of the cast (actually, there will be one vtable for each
class-iface pair that appears within a crate). By representing the
draw
instance as the pair of the instance data with the vtable, we
can easily have one class instances associated with any number of
vtables.
Type classes
There are two fundamental approaches to writing polymorphic functions
(in general, not just for interface types). The Java and C++
technique, which we illustrated in the previous section, is to use
subtyping. Another approach, pioneered in functional languages
(though it is also available in OOP languages) is to use parametric
(or “generic”) functions. For example, we could write a function
draw_many
like so:
fn draw_many<D:draw>(gfx: graphics_context, drawables: [D]) {
for drawables.each {|drawable|
drawable.draw(gfx)
}
}
draw_many()
looks very similar to draw_all
. It declares a type
parameters D
and says that the type D
must implement the draw
iface. This draw
interface is called the bound of the type
parameter D
, because it bounds (or “puts a limit”) on what types can
be used for D
: they must be types for which the interface draw
is
available. It then takes a vector of D
instances and iterates over
its contents, invoking the draw()
method on each value.
There is in fact a subtle different between draw_all()
and
draw_many()
. draw_all()
took a vector of type [draw]
: this
means that each entry in the vector may in fact correspond to a
distinct kind of drawable thing. For example, the vector might have a
square and a circle, as we saw. draw_many()
, in contast, takes a
vector of type [D]
. This means that the type D
could be a square
(which is drawable) or it could be a circle (which is also drawable),
but you cannot have a vector containing both a square and a circle.
To see more closely why this is, consider that at runtime we implement
generic functions like draw_many()
by following the C++ approach:
that is, we duplicate the function for each type that it is used with.
Therefore, we can easily create a version of draw_many()
for
squares by substituting square
for each use of the type D
:
fn draw_many<square>(gfx: graphics_context, drawables: [square]) {
for drawables.each {|drawable|
drawable.draw(gfx)
}
}
We can also create a similar one for circles, but there is no type
(other than draw
) that we could use to create a version that accepts
a vector containing both circles and squares. In fact, there can be
no such vector: all vectors must contain instances of a single type.
Using the type-class style of implementation is generally more efficient than the traditional OOP-style, because it produces no vtables at all (but it does produce more code, which has its own inefficiencies). This efficiency comes at the price of less flexibility, because the style cannot deal with heterogeneous collections.
Actually, this is not strictly true: it is (usually) allowed to
instantiate the type D
with an iface type, so we could still invoke
draw_many()
with a vector of draw instances, just as we did with
draw_all()
. This would be equally (in)efficient as the OOP version,
because all method calls would still go through a vtable.
Code reuse via traits
Inheritance is often used as a means of achieving code reuse in OOP languages. While it can be convenient, this is generally regarded as unfortunate, because it ties together the subtyping relationship with details about code reuse. A more modern approach is to make use of traits. Rust offers traits but I won’t go into detail here. In effect, traits allow you to factor out common method implementations in a much more flexible way than inheritance, without introducing the complications of traditional multiple inheritance.
Impls
So far, the only way to define a value with a method is to define a class and include the method in the class definition. This is too limiting, however, in two ways. First, sometimes we want to define methods outside the class body—for example, to extend a class defined in one crate or module from somewhere else. Second, not all types in Rust are classes (for example, ints and vectors) and we don’t want them to be, for efficiency reasons and C compatibility.
To address these two needs we allow you to define methods for a given
type using the keyword impl
. For example, suppose we want to add a
method bounds()
that computes a bounding rectangle for a shape. You
might do something like this:
impl bounds for square {
fn bounds() -> rect;
}
Here the syntax impl N for T
defines a suite of methods named N
for the type T
. You can also associate an impl
with an iface
like so:
iface bounds {
fn bounds() -> rect;
}
impl of bounds for square {
fn bounds() -> rect;
}
In this case, the name of the method suite is (by default) the name of
the iface. The full syntax is impl N of I for T
.
Using an impl
, we can generalize interfaces to apply to arbitrary
types. For example, we could implement the draw
interface for a
uint
(whatever that means):
impl of draw for uint {
fn draw(gfx: graphics_context) { ... }
}
Then a [uint]
could be passed to draw_many()
. Similarly, we could
cast a uint
to draw
.
Scoping of impls
In order to make use of the methods in an impl
, you must bring the
impl
into scope using an import statement. This is where the impl
name comes into play. So, to use the bounds
method from another
module, I must include something like:
import B::bounds;
where B
is the module containing the impl
declarations. The same
visibility rules apply when trying to cast a type to an iface or use
the type as the value for a bounded generic type parameter.
Mismatches
To some extent, the class and impl system were independently designed, and there are a few mismatches (mostly in code that has not been fully implemented). The main one is that interfaces are duck-typed (not declared) and impls declared when they implement an iface. We will align these to be the same (for the moment, probably initially by adding the ability to declare an interface when you declare a class).