"No abstract classes nightmare" they said

I've been making a big project and finally end up doing stuff that's kinda similar to abstract classes in Python.

Step 1: Data Structures

In the beginning I had several tables of data like this:

struct Table1 {
    map: HashMap<Key1, Row1>,
    extra_index: HashMap<Category1, Vec<Key1>>
}

struct Table2 {
    ...  // similar as in Table1, but with different types
}

I needed 4 such tables, with different types, which allows keeping natural keys and natural data, which is simpler to reason about and debug, rather than refined and abstract common data.

A hard requirement is that in one table, rows refer to one or two rows in another table, which complicates things and requires some accessors to raw data.

Step 2: Traits

I wanted to make common interfaces and created traits. They all were fine, except every TableX returned different types, so I needed associated types. And the rows they were returning, also had custom properties, hence another trait with another associated type. I ended up with 3 traits, each with associated type.

trait Tbl {
    type Row: Row;
    type ReferencedTable: Tbl;
    // some tables have none, so I had to impl Tbl for ()

   fn query(key: <<Self as Tbl>::Row as Row>::Key) -> Row;
   // aaargh, cannibal turbofish!
}

trait Row {
    type Key: Hash, blah blah blah;
    ...
}

Step 3: Putting Things Together

After I spent a month introducing the traits, refactoring and adjusting the code, I thought I wanted a common web API to make queries like "run an algorithm on table N for rows category M, params X, Y, Z".

Must be easy, doesn't it? Just Box-dyn the trait, and you're fine?

The Gotcha

Well, no, because Box<dyn Trait> returns associated types that come not boxed, and must be stone-hard specified.

Turns out, a trait has an associated type works similar to generic nested type, like MyType<X> can't be put in a vec with MyType<Y>, same way impl SomeType<AssocType=X> & impl SomeType<AssocType=Y> are different and can't be put together.

What I Did Wrong

Again, I see it could have been better had I start with writing the client code first, which shapes the internals, not the other way around. This is what I'm doing with a blank small crate now.

Enum like Abstract Class

Right now, I have to make a wrapper class that converts things like query data in common type => table X inner type => table X mapping => common algorithm data type => algorithm result => table X inner type => common output type.

A serious difficulty is that I must keep track of these inner-outer types, like ying-yang and separate them carefully, and the conflicts between them sometimes get uncovered only when you remove todo!()s and try implementing at least the wrappers.

The fact that the wrapper must process only the common types, means that concrete TableX classes must implement conversion themselves, hence there either will be copy&pasted code, or shared code by using traits & provided methods, which implies... associated types... again!

So, worries:

  • either a good deal of copy&pasted code.
  • or another gotcha with code in provided methods
  • the wrapper starts to look like abstract class...
  • or like God object

Any comments, advices?

It's a little hard to give advice with such a high-level description instead of actual code, but I think the piece you're missing might be concrete generic structs (eg Table<R:Row, I:Index<R>>), or possibly the idea of splitting each behavior into its own trait so that you can erase the details that don't matter for that operation without losing the ability for other code to still work with specifics.

5 Likes

I haven't seen such definition as "concrete generic structs", maybe I'm missing something, could you please elaborate?

It's not a technical term, just the best that I could come up with off the top of my head.

In general, there are two main ways to reduce code duplication in Rust. When you have a common structure, like your tables, you most often will not write a trait but instead a struct with generic parameters— Traits are more about common behaviour/capabilities.

Usually, these come in combination, with traits defining the behaviour needed for the generic struct to operate correctly. In your case, you might write something like this playground.

6 Likes

Oh, yes, that makes sense, thanks a lot!

Good (with tests)

[( This needs rephrasing but likely I won't. )] This (for traits) is only needed if client is doing something common but you don't show that. Calling different functions are not common. Common is when you stop caring such as reading/writing; you don't define the source. (or run a task given to you but don't care other than make it run.)

I read this a stringly-typed or similar. AKA not rust type system.
It looks like internal/external API but you try to only have one algorithm.

Well, all tables do exactly the same thing (2 same kinds of queries). They have natural keys and their "natural" data inside, both of which can be projected into a common format, suitable for the algorithm. The user that queries the web API can change "table1" to "table2" paramqeter and still get meaningful response.

Actually, good that you wrote this -- answering, I thought I could do the following:

  • those structs have a common trait without any assoc types
  • the trait methods accept one type for queries, and output one type
  • to avoid duplication of the projection, I should make a generic mapping struct

Thinking more of the traits I made, I see that associated types were introduced to connect two tables in a generic way. But it's not needed: in all cases, the client table knows its source exactly, so those input/output types -- like KeyX -- shouldn't be in the trait. If I drop that, I could probably keep the Tbl trait and actually use Box.

I tried in sandbox crate, and found that the idea to make a trait for a pair of table structs was the best one. I actually decided to make it a struct on its own. And then found out I had a helper struct with exact same composition -- except I imagined its use differently. (I guess it's because of Python habits to minimize the use of classes and have classes like something "material".)

The code didn't compile right away because dyn trait X structs need to follow some rules to be used as objects.