How to model inheritance hierarchy?

I am trying to see if Rust could be used on a project that I am working on and am stuck on how to model an inheritance hierarchy in Rust. I have many objects that are currently modeled as a hierarchy and I have read chapter 17 (oo design patterns) but the ideas there would require lots of duplicated code.

Consider the following example:

Transaction has the following state:

  1. Id
  2. Date
  3. Location
  4. Employee
  5. Detail Lines
    and so on

There are subclasses of Transaction such as SalesTransaction, PurchaseTransaction etc. SalesTransaction has following state:

  1. Customer
  2. Billing Address
  3. Shipping Address
  4. Payment Terms - Net 30, Net 15 etc.

SalesTransaction has sub classes like Invoice, Sales Receipt, Estimate, Order etc. Invoice has state as follows:

  1. Due Date
  2. AR Account
  3. Applied Payments
  4. Balance Due

Similarly, Estimate has some state unique to it but most of it inherited from SalesTransaction.

What is the best way to model this in Rust?

You could use composition, like

struct Transaction { ... }

struct SalesTransaction {
    transaction: Transaction,
    ...
}

struct Invoice {
    sales_transaction: SalesTransaction,
    ...
}

Then you can create some methods to convert references to the appropriate type, like so

impl Invoice {
    fn as_sales_transaction(&self) -> &SalesTrasaction {
        &self.sales_transaction
    }
}

This can be done with macros to reduce code duplication,

macro_rules! impl_as {
    () => {};
    (SalesTransaction) => {
        fn as_sales_transaction(&self) -> &SalesTransaction {
            &self.sales_transaction
        }

        fn as_transaction(&self) -> &Transaction {
            self.sales_transaction.as_transaction()
        }
        
        // more conversions?
    }
    (Transaction) => {
        fn as_transaction(&self) -> &Transaction {
            &self.transaction
        }
        
        // more conversions?
    }
}

And this can be used like so,

impl Invoice {
    impl_as! { SalesTransaction }
}
1 Like

Probably a better way to do it would be to center the whole thing around transaction IDs, like if you were doing it in a relational database.

struct TransactionId(usize);
struct Database {
    transactions: HashMap<TransactionId, TransactionData>,
    sales: HashMap<TransactionId, SalesData>,
    estimates: HashMap<TransactionId, EstimateData>,
    invoices: HashMap<TransactionId, InvoiceData>,
}

The advantage, as many game designers will happily point out, is that you avoid the Diamond Inheritance problem where you want a sort of transaction that has invoice characteristics and estimate information.

In fact, if this works the way I think it does, you're probably already employing some sort of "child transaction / parent transaction" parallel hierarchy in order to work around the limitations of single inheritance. What, exactly, is the relationship between an order and an invoice, and how do you implement that relation?

3 Likes

One crate that efficiently implements @notriddle's idea is specs, although that may be a bit more heavy weight than you need.

But that would mean any client of Invoice will have to do the following to access any fields in SalesTransaction or Transaction:

invoice.sales_transaction.customer

In addition to being inconvenient, it also makes moving fields difficult. Imagine dueDate was to be moved from Invoice to SalesTransaction; now, everywhere dueDate was used will require a change.

Also, it'll be difficult to create Invoice instances:

Invoice {
sales_transaction: SalesTransaction {
transaction: Transaction {
id: 1,
},
customer: ...,
},
dueDate: ...,
}

Another idea you can use to translate inheritance is composition, but inverted with the composed bit being a generic struct:

trait TransactionKind {
    /* whatever you need to be able to treat different kinds of transactions generically */
}

struct Transaction<K: TransactionKind> {
    /* stuff for all transactions */
    specialized: K,
}

struct Sales {
    /* stuff for sales transactions */
}

impl TransactionKind for Sales { ... }

and then instead of SalesTransaction, PurchaseTransaction, etc. you use Transaction<Sales>, Transaction<Purchase>, etc. and when you just want a generic Transaction you can use, well, generics.

I rather like this from an abstract perspective, but I can't say I've used it much.

Inheritance is quite a blunt instrument in some ways. Rust generally gives you all the tools you need to solve any problem you have. A mistake lots of people make is to start from a solution (that works in some other language), and try to translate it straight to Rust without doing the legwork. This rarely works particularly well between any two languages, but it's especially difficult translating a class-based design into a class-less language like Rust.

3 Likes

The second of the Rust koans addresses this topic.

2 Likes

I'm surprised I haven't seen this suggested so far, but I encourage you to try modeling this with enums. It's likely to be less code and easier to write. I use this pattern whenever I run into something that feels like inheritance, and it's worked great for me.

(I am making some assumptions here, which I will discuss at the end.)

Group the common fields into a struct, and include the variable part as a field. Here I'm calling it TransactionKind because it encodes the kind of transaction.

struct Transaction {
    id: Id,
    date: Date,
    location: Location,
    employee: EmployeeId,
    detail_lines: Vec<DetailLine>,
    kind: TransactionKind,  // <--- here
}

Now, implement TransactionKind to carry the variant data:

enum TransactionKind {
    Sales {
        customer: CustomerId,
        billing_address: Address,
        // and so on
        kind: SaleKind,
    },
    Purchase {
        // fields go here
    },
}

enum SaleKind {
    Invoice {
        due_date: Date,
        // and so on
    },
    Receipt { ... },
    // ...
}

Advantages of this approach

The entire hierarchy of options is presented right there, together. (With OO inheritance, you can often inherit from a class anywhere in the code, so you may need an IDE to find all the subclasses.)

It's also a lot less code than writing out specialized traits and whatnot.

You can use pattern matching to distinguish special cases. You may or may not like this, but it helps with a particular pattern in OO code where you either wind up using instanceof/dynamic_cast to check for a particular subclass, or implement a method that's empty except for one subclass...with an enum you can just check for the type you want:

if let Transaction::Sales { customer, .. } = transaction {
    println!("customer: {}", customer);
}

Disadvantages / limiting assumptions

All the "subclasses" (variants) have to be listed within the enum. Third parties can't add new ones without changing the code (as opposed to "open subclassing"). I argue that this is usually what you want -- arbitrary new subclasses can break your assumptions and your code -- but maybe you need open subclassing in your application.

You have to use pattern-matching to distinguish variants. Operations on enums wind up looking like a match or switch statement, and generating corresponding code. Operations on class hierarchies in OO languages tend to use "virtual dispatch," which generates different code (an indirect function call). Sometimes indirect function calls are faster, but I would strongly encourage you to profile your code before assuming this -- I've found the enum approach to generate faster code in my systems.

The way I wrote the enum above, every Transaction will be the size of the largest variant. That is, if PurchaseTransaction is relatively small and SalesTransaction is big, you're going to be allocating one SalesTransaction-worth of space for every Transaction. (That's how Rust enums work.) You can limit this by introducing indirection. For example, in SalesTransaction, you can box the contents to make the whole SalesTransaction the size of a pointer:

   // inside Transaction
   Sales(Box<SalesTransaction>),
...

// Fields have been separated into a struct so they can be boxed.
struct SalesTransaction {
    customer: CustomerId,
    billing_address: Address,
    // and so on
    kind: SaleKind,
}

(If you've done C++, this is basically equivalent to the pImpl pattern from Effective C++, but we use it for different reasons -- in C++ it's used to hide implementation details, which Rust does with modules.)

6 Likes