Virtual references are another kind of reference, distinct from simple and array 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 we can't know the concrete type at compile-time, as the decision of the value's type is made at run-time. All the reference knows is that it points to an object whose structure and behavior conforms to some specified trait.

A trait example

Let's illustrate this with an example:

struct Rectangle
  height f32
  width f32
  fn area(self &)
    height * width
  fn perimeter(self &)
    2. * (height + width)

struct Circle
  r f32
  fn area(self &)
    f32::pi * r * r
  fn perimeter(self &)
    2. * f32::pi * r

trait Shape
  fn area(self &)
  fn perimeter(self &)
	
fn doMath(shape &<Shape) f32, f32
  shape.area, shape.perimeter
	
fn tester()
  imm circle = Circle[5]
  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 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 be triggered if the type does not conform to the expected trait. 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.

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
  ~~Circle:
    // 'shape' is now usable as having type &Circle
  ~~Rectangle:
    // 'shape' 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 need pattern matching to figure out which concrete type originally created the object.

Trait Restrictions

Since traits may serve in multiple roles, some traits may not be suitable or limited 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 trait makes any use of Self in any form other than as a virtual reference (&<Self).

enum-based Virtual References

Besides traits, virtual references can be also be created from enum-typed values. Think of the enum type as if it were acting in the role of a trait. In order for the virtual reference to dispatch to methods, outer enum type needs to define signatures for all the methods it expects to find in common across all the variant types it enumerates. Obviously, every variant type also needs to have implementations for all those methods.

Instead of using a trait, we could define our example above with Shape as an enum:

enum Shape
  fn area(self &)
  fn perimeter(self &)
	
  Circle
  Rectangle

A virtual reference built on an enum works the same way as one built using traits. This approach can be valuable when you already want to use sum types for other reasons (fixed-sized, value-based data), but also want to leverage dynamic dispatch where that sort of runtime, method-based dispatch flexibility is required.

Closure References

Earlier, we introduced value-based closures. Value-based closures struggle when we need closures that have a specific calling signature, but whose underlying struct state may vary in size and fields. To satisfy this requirement, a closure should be created as a virtual reference.

Let's illustrate this with a simple event-handling example:

window.whenClicked(&<so |{window}| { ... })

Here we allocate a new click-handling closure using the single-owner region (ensuring it is automatically freed after being triggered and discarded). The closure's state remembers the window, which its logic uses when the window is clicked.

The whenClicked method accepts any closure whose type signature is &<so ||. In other words, it accepts closures having any underlying state, so long as the closure's function signature matches (it accepts no arguments and returns no values).

Closures allocated this way are virtual references. In the same way that closures are implicit structs, closure references take advantage of an implicit trait, one which defines the appropriate method signature for the () method.

When the event fires on a click, the event handler simply calls the closure to perform its defined logic:

handler()

_