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 fn area(self &) f32 fn perimeter(self &) f32 fn doMath(shape &<Shape) f32, f32 shape.area, shape.perimeter fn tester() imm circle = Circle doMath(&circle) // 78.5, 31.4 imm square = 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.
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, trait, or enumtrait. 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.
The primary use of virtual references lies with the dot operator. This is how one dispatches trait-declared methods and accesses trait-declared fields.
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.
Some types may not be suitable when used as a foundation for virtual references. This would be the case if any method were defined as a generic function. It would also happen if the type makes any use of Self in any form other than as a virtual reference (&<Self).