Introduction

Welcome to the third and final installment of our series on how we implemented a runtime reflection system in Rust.

So far, we’ve shown how we came up with a fairly simple Class and Instance model for thinking about runtime Rust classes. In Part 1, we used these for type checking, and in Part 2 we added support for reading attributes off of a struct.

In this post, we pick up where we left off with attribute getters, and expand into method calls. In some ways, the same techniques we used for attributes work just as well here. We can store a map from method name to functions implementing them. However, there’s a curveball: the Rust Fn* traits. We’ll talk through the wrong turns we took, and the tidbits of Rust knowledge we picked up along the way.

Method Calls

Now that we have classes, instances, and attributes, the next obvious step is to add methods.

In oso policies, it is possible to call both class and instance methods, with and without arguments.

So given the struct:

We should be able to write policy logic:

Which says that the input food is the cat’s favourite food if the result of feeding the cat is not the same as the result of the cat meowing.

Step 1: Zero arguments

Let’s start with a simple implementation for methods that take zero arguments. The approach for implementing zero-argument methods is extremely similar to how we’d implement attribute getters, and we’ve actually done this already in Part 2:

Similarly, we need to add a method onto our ClassBuilder struct to allow us to register new methods:

Super easy! End of blog post. See you next time 😎

But wait, what about methods with multiple arguments?

Step 2: Multiple Arguments and the Fn* traits

Let’s take what we have above and add in support for multiple arguments.

This mostly works for what we need! Polar doesn’t care about method arities (how many arguments the method accepts) – it will send over however many arguments it has as a vector. Polar supports both variable number of arguments (varargs) and keyword arguments (kwargs). The latter is only supported for host languages that also have that concept, and Rust does not.

But this isn’t the end of the story. The crucial part of the AttributeGetter interface was that you could pass in any method or closure for the attribute getter, and the AttributeGetter::new method handled all the type-conversions transparently. This hid the messy, error-prone details from the user and kept the interface clean.

So let’s do the same for InstanceMethod!

We’ve hit our first problem.

The input to an attribute getter never accepted any arguments, and we only needed it to work for all Fn(&T). In order to support multiple arguments, we now need to cover Fn(&T)Fn(&T, A)Fn(&T, A, B), and so on.

The first problem is how to convert AB, etc. into PolarValues.

The second problem is that these are all completely distinct traits. There is no trait capturing “functions of arity 2, 3, 4…”. – at least not until the feature is available in stable Rust. In the future, this might be represented with the syntax Fn<Args, Output=T>, but right now using this syntax results in:

We could opt to use Rust nightly to get these features, but it’s not a huge stretch to implement them ourselves.

Implementing our own Method trait

This is where as the writer it’s tempting to unveil my newly-created trait, perfectly matching what we needed, and make it look like I just put fingers to keyboard to get to the definition.

In reality, we spent a sizeable chunk of our engineering effort for this project on this one trait. We made mistakes. We wrote code that we threw out. And a lot of that is because we didn’t understand some of the nuances of Rust functions and the trait resolution system. Instead of papering over all of that, we thought it would be more interesting to show you what we tried.

Attempt #1

The first thing we tried was, in hindsight, a little greedy. Why not just skip straight to writing a trait to encapsulate precisely what we need?

This looks great, let’s try implementing it for one of our Fn variants:

Results in:

It’s likely that most people have hit some variation of this error in their Rust adventures! What is going on here? T and R look pretty constrained to me? They are right there inside the definition of F: Fn(&T) -> R.

Our mistake was reading those trait bounds as: F is a function from &T to R, whereas in reality this is a regular old trait bound with slightly different syntax for the trait itself. And one function might implement multiple of these trait bounds.

E.g.

So which trait should we use for the implementation of Method for ident?

Here’s another way of looking at this problem. Suppose instead we decided to exhaustively implement our Method trait for all types we care about:

Ignoring for now just how bad an idea this is, it doesn’t even work! We get:

Look back to ident. That one function implements both Fn(String) -> String and Fn(u32) -> u32 traits. Similarly, in the above case, a function that implements both Fn(&String) -> String and Fn(&u32) -> u32 would have two possible implementations for Method. So we get conflicting implementations.

Actually, it goes even further than that. Implementing a blanket trait implementation over any two function trait bounds results in conflicting implementations. Even though such a function couldn’t exist:

Results in:

One day (or today, if you’re on Rust nightly), Rust might let you implement Fn for your own types, support variadic functions (what do you mean Rust already supports variadic functions?), and do all kinds of fun things of the sort. But for now we’re not going to get much farther with this approach.

Attempt #2

This Method trait looks too convenient to throw away entirely at the first sign of complication. Let’s try something different. If our original mistake was thinking of Fn as a function instead of a trait, perhaps we can heed the wisdom of the Rust docs and use fn instead.

Based on the docs, we can use fn with both regular functions and closures! Great, let’s do just that:

Works fine! Let’s try it out:

And…

It doesn’t work for closures? What if we change clone to a proper function fn clone(s: &u32) -> String { s.to_string() }

We have the same problem.

Notice the syntax fn(&'r u32) -> String {main::clone} The additional block statement is the important part. Each function item gets a unique anonymous type, as does each closure. Both can be coerced into a function pointer. But this coercion doesn’t happen automatically for resolving trait bounds.

This error is particularly tough to spot. And on first glance it might look like the trait is not implemented correctly. There is actually an open issue about the error messages here: https://github.com/rust-lang/rust/issues/62385.

The fix is to write:

But we would be punting this work to the end users of our library, and that didn’t seem acceptable

Attempt #128

Eventually, we landed back at something more similar to the unstable Rust Fn trait, where the trait has a generic Args which is a tuple of the input arguments.

You may still be wondering why we need the extra receiver: &T argument? This is due to limitations in how we can express tuples of types.

What we would like to have is a single trait Function<Args> – where Args is a tuple – then define Method as something like trait Method<T, Args>: Function<(&T, ...Args)> – where ...Args is some kind of tuple-spread operator.

There’s an open issue for something like this: Variadic generics. But so far every attempt at an RFC has been closed or postponed.

So until then — or until we decide to implement it using something like heterogenous lists with frunk — we’ll do it the long way.

Step 3: Implementing our method trait

We’re almost at the finish line. We just need to implement our new trait for every possible number of arguments.

For example, the two-argument version looks like:

This is pretty simple, but it’s also exactly the kind of thing that a macro can make less painful. The final version looks like:

There are actually a few more pieces we need. For example, how do we go from Vec<PolarValue> to (A, B, ..)? More traits and more macros!

The end result is exactly the experience we were looking for: you can pass in Rust closures, or functions:

Comparisons

It turns out that if you build a language on top of Rust, there’s a good chance you’ll end up implementing this trait. For example:

Extensions

Class Methods

We also want to support class methods:

Here, the type check cat: Cat is using the symbol Cat as a class tag, whereas anywhere else, Cat is a variable name bound to the Cat class. We borrowed this pattern from the corresponding Python implementation, where we bind the variable to the value of Cat, i.e. <class 'Cat'>. Since Python is dynamic and we don’t go through the same class registration steps as we do in Rust, it all just works.

But we can apply the same general idea here, and create our own metaclass. And it works.

First, we need to register Class as the type metaclass:

To then dispatch class methods, we need to make sure that “instance methods” of the Class metaclass take the remaining input arguments (the receiver type here being Class), and call the actual class method.

For example: on invoking Cat.meow, this resolves to a method "meow" on CatCat is a constant, and is an instance of Class. So "meow" is an instance method on oso::host::Class. And we have a special hook on the instance method dispatch, which checks whether the current class is Class :

Which means the Cat::meow class method gets returned instead of an instance method.

Future Work

Our implementation is in a pretty good spot for most use cases! You can see the latest version of the oso Rust crate here.

But there’s a lot more we can do from here. First, we can add support for other variants of methods. We didn’t cover it here, but we currently support returning results, options, and iterators, the last of which requires using a special add_iter_method. We could also support async methods.

Furthermore, we currently restrict users to defining one method per name, even though that’s not a restriction that Polar enforces. Because of the approach we took above, it would be challenging to support fully generic methods (like our ident<T> method), but we could make it possible to add a method for multiple types. We could even add support for variadic arguments if we wanted!

Summary

This concludes our series on building a runtime reflection in Rust!

We started out slowly in Part 1, leaning on Rust’s built-in support for runtime dynamic types through the Any trait as the foundation of our class system. With that in place, we could do some simple runtime type checking. ✅

In Part 2, we looked at pulling attributes off structs at runtime. Here we had a terrible choice to make: require users to add #[repr(C)] to all their structs so we could do ~~dark incantations~~ pointer arithmetic and read fields dynamically? Or make attribute accessors an explicit opt-in? The latter seemed like more of a Rust approach, and we took it.

Which leaves us here in Part 3. We built on our attribute getter approach, turning it into an instance method and allowing inputs. We earned the final piece with a few scars to show for it, and we have our own runtime reflection system for Rust.