Hi all, I started work on this RFC, any and all feedback is appreciated!
Summary
We can support slices of traits by storing the v-table pointer once per slice instead of once per item. This is limited to slices where all the items have the same concrete type. In other words, the following conversion should be possible: &[T: Trait] -> &dyn [Trait]
Motivation
Suppose we are abstracting multiple API providers that do mostly the same thing. This can be almost anything where there are multiple competing products doing similar things, each with their own API. In this example I will focus on graphics APIs (i.e. DirectX, Vulkan, Metal, OpenGL, etc), since that is where I personally felt the need for this feature.
[!NOTE] This is specifically relevant when multiple implementations should be supported in a single binary. For example, bluetooth also needs multiple implementations because each platform has its own API, but you wouldn't enable more than one implementation at the same time. For these cases, a
cfg
switch works better thandyn
.
I want to use the abstraction of a swapchain as a motivating example. A swapchain is an object that manages the presentation of rendered content to the screen. This swapchain usually contains multiple images; one that is presented to the screen, and another one that you can draw to directly. These then get swapped every frame (hence the name).
Lets start with the abstraction of an image:
trait Image {
fn size(&self) -> (u32, u32);
}
struct DirectXImage { ... }
struct VulkanImage { ... }
impl Image for DirectXImage { ... }
impl Image for VulkanImage { ... }
This then allows us get the size of an image, without knowing the underlying API, using &dyn Image
:
fn do_something_with_image(image: &dyn Image) {
let size = image.size();
...
// profit
}
Now, when we try to do the same thing and abstract the swapchain, we run into an interesting issue:
trait Swapchain {
fn images(&self) -> /* What do we return here? */
}
struct DirectXSwapchain { ... }
struct VulkanSwapchain { ... }
impl Swapchain for DirectXSwapchain { ... }
impl Swapchain for VulkanSwapchain { ... }
Associated type
The first logical thing to do seems to add an associated type, like so:
trait Swapchain {
type Image: Image;
fn images(&self) -> &[Self::Image];
}
However, when you try to use a dyn Swapchain
, you will get the following error:
error[E0191]: the value of the associated type `Image` in `Swapchain` must be specified
This is because associated types are not object safe. We can't know the type of Image
if all we have is a dyn Swapchain
.
dyn Image
From the previous example we can deduce that we need Image
to be dyn
as well, since we have no way of knowing the concrete type. We can try some signatures, all of them with their own issues:
trait Swapchain {
// error[E0277]: the size for values of type `dyn Image` cannot be known at compilation time
fn images(&self) -> &[dyn Image];
// error[E0277]: the size for values of type `[&dyn Image]` cannot be known at compilation time
fn images(&self) -> [&dyn Image];
// Good luck making an implementation that satisfies the borrow checker
fn images(&self) -> &[&dyn Image];
// This works, but will discuss why it's not desired below.
fn images(&self) -> &[Box<dyn Image>];
}
Example 1 and 2 don't even compile. Example 3 looks like it would be incredibly hard to implement, if possible at all in safe rust.
Finally example 4. It works for sure, and the caller of this API probably doesn't care too much whether it's a slice of Box<dyn Image>
or &dyn Image
. However, lets look at the implementation instead:
struct VulkanSwapchain {
// Cannot do this!
// images: Vec<VulkanImage>
// Needs to be stored like this instead
images: Vec<Box<dyn Image>>
}
impl Swapchain for VulkanSwapchain {
fn images(&self) -> &[Box<dyn Image>] {
&*self.images
}
}
Now the implementation has to deal with dyn Image
objects, even though it knows exactly what type it is.
Give up and use indices?
We could of course give up and use indices instead:
trait Swapchain {
fn image(&self, index: usize) -> &dyn Self::Image;
fn image_count(&self) -> usize;
}
But this removes all the ergonomic benefits of having an actual slice. Iterating over these images becomes a lot more cumbersome. We seem to be having to decide whether the caller or the implementation has to deal with the workaround. I also believe there is a solution that can be the best of both worlds.
The root of the problem
The root of the problem lies in the fact that each item of the slice is it's own dyn
type. This means that each item is now a fat pointer, with it's own pointer to the v-table. This increases the size of each item, making it impossible to transmute to and from a slice of a concrete type.
However, in this case we know that each and every item has the same type. So why do we need a v-table pointer for each item anyway? If we could instead make the entire slice dyn
, meaning that each item shares the same v-table, we would no longer have that problem.
The solution
trait Swapchain {
// Make the slice itself dyn instead.
fn images(&self) -> &dyn [Image];
}
struct VulkanSwapchain {
// Now we can still do this!
images: Vec<VulkanImage>
}
impl Swapchain for VulkanSwapchain {
fn images(&self) -> &dyn [Image] {
&*self.images
}
}
Here we can have our cake and eat it too! The caller can get a simple slice to the images, while the implementation can keep a Vec
with the concrete implementation. Since the v-table pointer is now stored once per slice instead of once per item, the size of the items does not change, allowing it to easily be transmuted to a dyn
version of the slice.