Virtual References
On this page
Note: Borrowed virtual references are implemented. An owning one (So[Shape]) is built, dispatched through, lent as a borrowed one, and finalized and freed when its last owner goes, but a &<uni borrow still moves it. A method is dispatched only through a virtual reference whose permission grants what its self declares, but dispatching through a &<uni hands the method that reference itself, which moves it. A lifetime bound, &<Shape + 'a, is implemented on a borrowed virtual reference; an owning one takes none yet, and is bounded by 'static.
Virtual references are distinct from simple references. A virtual reference is used to access some of an object's fields or invoke some of its methods, without actually knowing its specific, concrete type. This is necessary when runtime conditional logic prevents us from knowing the concrete type at compile-time. All a virtual reference knows is that it points to an object whose structure (fields) and behavior (methods) conform to some specified type.
A trait example#
Let's illustrate this with an example:
struct Rectangle;
height f32;
width f32;
fn area(self &) f32;
height * width;
fn perimeter(self &) f32;
2. * (height + width);
struct Circle;
r f32;
fn area(self &) f32;
f32.pi * r * r;
fn perimeter(self &) f32;
2. * f32.pi * r;
trait Shape;
pub fn area(self &) f32;
pub fn perimeter(self &) f32;
fn doMath(shape &<Shape) f32, f32;
shape.area, shape.perimeter;
fn tester();
imm circle = new Circle(5);
doMath(&circle); // 78.5, 31.4
imm square = new Rectangle(10, 10);
doMath(&square); // 100, 40
We define two different types, Rectangle and Circle, each with different fields. However, both of them implement the area and perimeter methods. We also define a Shape trait that declares the same two methods. Despite the fact that Rectangle and Circle never say that they comply with (nor inherit from) the Shape trait, the fact that both types implement all its methods means they are compliant with this trait.
The doMath function accepts a virtual reference (denoted by &<) to some object whose type is compliant with the Shape trait. Using this reference, it returns two numbers calculated by applying the area and perimeter methods on the object. If the object happens to be a Rectangle, it will use the methods defined for Rectangle. Likewise, it will use the Circle methods, if that is actual type of the object.
The tester function demonstrates this behavior by creating a specific circle and rectangle, borrowing a reference to each, and then calling doMath on them both. The calculated results we get back reflect that the right method was used, based on the actual type of the object.
How the Magic Works#
Even though doMath seems to have no idea what the concrete type is of shape, it nonetheless correctly invokes the appropriate concrete type method. It can do this because virtual references are "fat" references containing two pieces of information: a reference to the real object itself and a reference to a vtable. This vtable makes the magic possible by describing key information about the object's concrete type as viewed from the perspective of the trait. In particular, it captures reference addresses for the type's trait-declared methods and location information for trait-declared fields, as well as unique id identifying the concrete type and the type's size, alignment and finalizer, which an owning virtual reference needs to release the object.
In this example, the vtable contains two references to the concrete type's area and perimeter methods. There are actually two different versions of this vtable, one for references to Circle objects and a different one for references to Rectangle objects. When a borrowed reference to one of those two types of objects is passed to doMath, a virtual reference is automatically created by attaching the type-specific vtable to the known pointer to the object.
When doMath invokes a method using a virtual reference, it retrieves a reference to that method out of the virtual reference's vtable. Then it calls that method passing the appropriate information, in this case the reference to the object. Calling a method indirectly (via a vtable) is called dynamic (or virtual) dispatch.
Working with Virtual References#
As the example above shows, a virtual reference is automatically created (coerced) when passing a regular reference to a variable or function expecting a virtual reference. This is called upcasting.
A compiler error will result if the concrete type does not structurally conform to the virtual reference's expected type. The virtual reference's expected type can be a struct or trait. Compliance simply means that all the fields and methods defined on the reference's type have been implemented by the concrete type. The compliant type may implement more fields and methods, but it must implement at least the ones the reference's type defines.
This coercion may be explicitly requested using the as operator:
mut virtref = &circle as &<Shape;
Virtual references can be owning or borrowed. They can be freely passed around, stored, or compared much like all other references. However, virtual references may not be de-referenced using the * operator, as a virtual reference can point to objects of very different size and data composition.
An owning virtual reference's type is written as any owning reference type is, So[Shape]: it is virtual because its value type is an open trait. It is created by coercing an owning reference, and it lends as any owning reference does (see borrowed references, "Borrowing from another reference"): where a borrowed virtual reference is wanted, the owner is borrowed from, not moved. Dispatching a method through it hands the method a borrowed virtual reference with the permission its self declares, so the owner may be used again after the call, and the owner may not be replaced or moved while a borrow lent from it is still to be used, nor lent mutably to one call beside another lend or a move of it (app.mix(app), for a self &mut method, is refused):
mut app So[Shape] = new So[Circle](5); app.area(); // lends app to the method imm view &<Shape = app; // borrows from app doMath(app); // and so does passing it
An owning virtual reference owns its object exactly as the owning reference it was coerced from did: that reference is moved into it (or, for a counted region like Rc, copied as one more owner), and when its last owner goes away — out of scope, replaced, or inside a struct that dies — the object is finalized as its concrete type and its memory is given back to its region. The vtable carries what that needs to know about the concrete type.
A virtual reference hides what the value it points at holds, borrows included. In a function's signature or a struct's field, a bound says how long those last: &<Shape + 'a points at a value whose borrows all last at least as long as 'a (see lifetime bounds). The type then holds 'a beside its own lifetime, so what it carries goes, wherever it is stored and read back out, where an 'a borrow may; and a value coerced to it where it is returned or stored through a parameter may hold only borrows known to last 'a. &<Shape + 'static points at a value holding only global borrows. With no bound, a borrowed virtual reference holds its own lifetime only.
An owning virtual reference names no lifetime at all, so it may outlive any borrow: So[Shape] is bounded by 'static. A value holding a borrow that is not global may not be made one, since nothing after it would follow what that value holds (see lifetime bounds):
struct Ring is Shape { pub inner &Circle; ... } // area and perimeter are inner's
mut app So[Shape] = new So[Circle](5); // Ok: a Circle holds no borrow
imm c = new Circle(2);
app = new So[Ring](new Ring(&c)); // ** ILLEGAL! a Ring holds a borrow of c **
The primary use of virtual references lies with the dot operator. This is how one dispatches trait-declared methods and accesses trait-declared fields. A virtual reference's permission governs what it may do exactly as a plain reference's does: a method is dispatched through it only when the reference's permission grants what the method's self declares. A method taking self &mut needs a virtual reference that permits mutation (&<mut, &<uni, or an owner such as So[Shape]); through a &< or &<imm one, only methods taking self & may be called.
trait Growable; pub fn grow(self &mut); fn enlarge(g &<mut Growable); g.grow(); // a mutable virtual reference may fn peek(g &<Growable); g.grow(); // ** ILLEGAL! g grants no mutation **
The same holds from the other side. A call through the trait lends only the permission the trait's method declares, so a type's method may not ask for a stronger self than that: self &mut where the trait says self &, self &uni where it says self &mut, or self &imm where it says self &. Giving such a type as the virtual reference is refused, naming both methods. A weaker one is fine: a trait's self &mut is met by a method taking self &. A closure literal given as the trait is no exception: one that changes state needs self &mut and is refused where the trait's method says self &.
trait Reading;
pub fn read(self &) i64;
struct Counter;
n i64;
pub fn read(self &mut) i64 { n = n + 1; n; }
fn peek(r &<Reading) i64 { r.read(); }
peek(&c); // ** ILLEGAL! Counter's read takes self &mut, Reading's takes self & **
Pattern matching#
Sometimes we want to re-obtain the specific, concrete-type reference to an object we have a virtual reference for. This is called downcasting. This can be accomplished using pattern matching:
match shape;
imm circle &<Circle {
// circle is now usable as having type &Circle
}
im rect <Rectangle {
// 'rect' is now usable as having type &Rectangle
}
_ {
// some other unknown shape type
}
Since a virtual reference might have been created from objects of many different types, we use pattern matching to figure out which concrete type originally created the object, information held by the vtable. So each pattern names a concrete type: a trait or an enum is refused there, since no object is created as one. A virtual reference made from a reference to an enum was made from one of its variants, and it is the variant that a pattern names. A trait is a pattern only for the other direction, a match on an enum, where it names the variants that comply with the trait and binds a virtual reference built from one (Trait patterns); an open trait has no such pattern.
Type Restrictions#
Some types may not be suitable when used as a foundation for virtual references. This would be the case if any method the trait requires were generic: a vtable slot holds one signature and a generic method has one per instantiation, so the compiler refuses the virtual reference and names the method. A generic method that is private, or a generic function with no self, is dispatched through nothing and so costs the trait nothing, and neither does a generic default (a method given a body), which is cloned into each type that declares the trait and is reached through that type, never through a slot. A default that is inline, or exists only where a where condition holds, has no slot either. The vtable holds only the trait's public methods and fields: a private one is not a requirement and has no slot, so it cannot be reached through a virtual reference, even from the trait's own module, where its name is visible. The compiler refuses the use and names the member and the trait; declaring it pub in the trait gives it a slot. The virtual reference itself is not refused, and a private method is still reached through self in the trait's defaults and on the concrete type. It would also happen if the type makes any use of Self in any form other than as a virtual reference (&<Self).