Sharing methods between structs/traits/trait objects

I'm a relative Rust noob, coming from a Python background. Really enjoying the learning experience so far - and in particular the top-notch documentation and community!

I'm up against a situation that I'm not sure how to approach. So far I've implemented a simple key-value store with SQLite as a backend. My current implementation is in the playground here. It creates an instance of the Db struct that includes a connection an in-memory SQLite database, inserts an Cat object (serialized as JSON), and reads it back out. So far so good.

Currently he Db struct has the public methods open(), get(), and set().

Now, I'd like to split my implementation in two different types of database access:

  • One would be DbRW, which incorporates all of the above functionality.
  • The other would be DbRO, which only allows read-only functionality (so open(), and get())

(FWIW, the reason I want this is that in reality this would be used with an on-disk database that's accessed by multiple processes, and I'd like to be explicit about the type of access that each process would have)

Obviously I don't want to have separate implementations of DbRO and DbRW which essentially duplicate the open() and get() methods. [In principle the open() method would actually differ slightly, since the DbRO version would open the database itself in read-only mode]

Where I'm stuck is that I'm not sure how to approach this from a trait/struct perspective. I've read pieces like Implementing an Object-Oriented Design Pattern - The Rust Programming Language and Traits - Rust By Example but I'm not sure how to apply them in this case.

My best guess was to have DbRW and DbRO be structs, with a common Db trait that contains the common logic and returns the right type of struct on open() - but attempting to implement that quickly throws up "object safety" issues that I'm not sure how to address.

error[E0038]: the trait `Db` cannot be made into an object

Would appreciate any pointers on how to achieve something like the above in Rust?

1 Like

You could e.g. just give Db an extra parameter. So you have Db<RW> and Db<RO>, and code can also be generic if it works with either.

This uses some trickery to get a more-or-less “equivalent” to an enum const generic argument (so, yeah, the part with the sealed trait with associated const, and two empty enums is probably hard to understand but...), here's what I got: Rust Playground

Edit: A similar approach using an actual const generic (bool) is also possible: Rust Playground

8 Likes

One approach is to do it in the exact opposite way.

trait Database {
  fn get_cat_by_id(&self, id: u32) -> Result<Cat, Error>;
}

trait MutableDatabase: Database {
  fn set_cat(&self, cat: Cat) -> Result<(), Error>;
}

struct SqliteDatabase { ... }

impl SqliteDatabase {
  pub fn open(connection_string: &str) -> Result<Self, Error> { ... }
}

impl Database for SqliteDatabase { ... }
impl MutableDatabase for SqliteDatabase { ... }

Here, the MutableDatabase: Database bit means anyone implementing MutableDatabase must also implement Database.

To understand the underlying reason for why you are seeing object safety errors, check out this article from a couple years ago.

Normally there are two solutions,

  1. Make the open() method explicitly opt out of being used in a trait object by adding a where Self: Sized clause
  2. Don't put your open() constructor in the trait at all, instead leave it up to the caller to construct the object and pass it into your code

I always advocate for the second solution because - outside of niche use cases - you will never actually be constructing an object generically and so you don't actually need it to be part of your trait. This also comes with the massive benefit that implementations aren't forced to go through a single constructor, and are therefore free to return their own custom errors or take different options when opening a connection.

3 Likes

Thanks both for the advice - this is incredibly helpful! I'll check these out in more detail, but for now one thing is clear: there is a lot more to learn :sweat_smile:

@Michael-F-Bryan for a start I had a go at implementing your recommendations - since they were closer to what I originally had in mind. Also I figured doing this would hopefully firm up my understanding of how to use traits/structs more broadly. FWIW, the piece that really helped me here was knowing that I can do something like

trait MutableDatabase: Database { ... }

to define supersets of methods.

What I ended up with is this. Following the advice

(or at least my interpretation of it), I defined the traits directly on the rusqlite::Connection struct, and initiated that struct separately.

That seems to work well, but there's one thing I don't quite get: given that both the Database and MutableDatabase traits are defined for Connection objects, how can I specify which Connections are read-write vs read-only? I can of course open a particular database connection with the relevant parameters, but it seems that e.g. the set() method will exist for it, even if the connection itself is read-only. Maybe I've somehow missed that part?

To half-answer my own question, it seems that in order to determine the "type" of database that I'm initializing (RO or RW), I should be able to do e.g.

let conn = <Connection as Database>::open(SQLITE_PATH)?;

or <Connection as MutableDatabase> for RW. That doesn't directly work with my latest implementation since I removed the open() function from the Database trait - but if I re-implemented it I suspect it would do what I need.

Given you are reaching for traits, I'm assuming you want to use this code in a generic context (e.g. with a database backed by SQLite or Postgres or whatever). If that's the case, you can write your where clauses to require the desired trait:

fn mutates_something_in_the_db<D: MutableDatabase>(db: D) { ... }

fn only_needs_readonly_access<D: Database>(db: D) { ... }

However, if you don't care about switching between implementations, you can drop all this trait business altogether.

@steffahn's approach with "tagging" the database connection could be an elegant solution here, although I'd use a marker type instead of const generics.

use std::marker::PhantomData;

struct Readonly;
struct ReadWite;

struct Database<Tag>(rusqlite::Connection, PhantomData<Tag>);

// methods that are only available for readonly connections
impl Database<Readonly> {
  fn open(...) -> Result<Self, Error> {
    let conn: rusqlite::Connection = ...;
    Ok(Database(conn, PhantomData))
  }
}

// methods that are only available for read/write connections
impl Database<ReadWrite> {
  fn open_writable(...) -> Result<Self, Error> {
    let conn: rusqlite::Connection = ...;
    Ok(Database(conn, PhantomData))
  }

  fn update_cat(&self, ...) -> Result<(), Error> { ... }
}

// methods that are available regardless of the tag
impl<Tag> Database<Tag> {
  fn get_cat_by_id(&self, id: u32) -> Result<Cat, Error> { ... }
}

Regarding the more general question of "should I use traits to define common behaviour?", I would suggest checking out this article by Matlkad:

Another thing to consider is the Go proverb,

a little copying is better than a little dependency

Which, in this context, would mean that sometimes it's easier to duplicate simple code (e.g. the open() and get() methods) than create elaborate hierarchies or use fancy type system tricks. Sometimes the dumb solution is the best solution.

Anyways, that's a couple tricks for your toolbox. Whether any of them make sense in your context will depend on the rest of the codebase and your preferred coding style.

4 Likes

Thanks for the tips @Michael-F-Bryan! In retrospect I agree that traits probably aren't the best tool for this. What I was basically reaching for was Rust's equivalent of OOP classes+subclasses - which I knew from the outset doesn't exist - though my initial hunch was that I could use traits to achieve something similar.

And while traits weren't the solution here, I do feel like playing around with them has given me a much better grasp of how they work, and the sorts of use cases where they would make sense. Thanks also for the additional posts you linked to, which made for great reading!

Further to that, the idea of "tagging" the struct either with a const generic or a marker isn't something I'd come across before, but certainly seems like a neat way to go! I personally find the version with the boolean const generic probably the most readable (especially after reading up on const generics here). In general, are there any specific pros and cons of using a const generic vs a PhantomData "tag" in cases like this? I can see how the latter might have added flexibility in more complex situations, but otherwise they seem quite similar.

1 Like

One thing you can consider is just making it an ordinary field, like struct Database<Tag>(rusqlite::Connection, Tag);. After all, struct Readonly; is a ZST, so it takes zero space in the same way as PhantomData does. And then if you find a reason to keep some runtime fields in there you can if you want.

(In this case you probably wouldn't, but in general you can use this like a "strategy pattern" to carry extra stuff -- whether data or code -- if you want.)

2 Likes

Yeah, for what you are doing they are functionally equivalent.

Both versions let you write where-clauses with them (e.g. where T: MyStrategy or where SomeType<const N>: SomeTrait), but more complex statements around const-generics (e.g. where N > 42) aren't fully implemented.

I prefer the type version because it lets you use it as a... uhh... type. For more complex scenarios you could create instances of the type and do things with it, or let users pass in their own implementation (e.g. to implement the strategy pattern @scottmcm mentioned).

1 Like

Thanks all, this is really helpful! I'm again pretty blown away by how supportive and welcoming to a newcomer the Rust community is. Hope to be able to contribute back some day!

2 Likes