Andrzej unjello Lichnerowicz

Rust Inherited

2025-02-04T16:54:15+02:00

I was working on a plugin system the other day and ran into an interesting problem. Imagine you’re building a distributed map/reduce framework. The core operations – filter, map, and fold – are conceptually the same, but each function is executed as a separate binary by the platform. This binary reads incoming data, transforms it into a Vec, does its thing, and then returns a Vec for serialization and further processing.

Most of the logic is shared: serialization, deserialization, protocol handling, maybe even some keepalives. But there are also unique requirements for each function—for example, fold needs to maintain an accumulator.

From the start, I had a few goals in mind:

  1. The SDK should abstract away all protocol handling, exposing only the core data processing functionality.
  2. The setup should be minimal.
  3. The design should be self-explanatory.
  4. It should be foolproof – each plugin type (Filter, Map, Fold) should be mutually exclusive.

The OOP Approach

If I were doing this in Python or C++, I’d reach for inheritance. I’d create a base Plugin class with most of the functionality and subclass it for Filter, Map, and Fold. Something like this:


class Plugin:
  def process(self):
    self.protocol_handshake()
    input = self.receive_records()
    output = self.handle_records(input)
    if output:
      self.send_records(output)

  def handle_records(self, input):
    raise NotImplementedError

class Filter(Plugin):)
  def handle_records(self, input):
    return self.filter(input)

  def filter(self, input):
    raise NotImplementedError

class Map(Plugin):
  def handle_records(self, input):
    return self.map(input)

  def map(self, input):
    raise NotImplementedError

A developer using this SDK would just subclass one of these:

from sdk import Filter

class MyGreenGrassFilter(Filter):
  def filter(self, input):
    return None if 'failed' in input else input

This is clean, easy to understand, and exactly what I’d want from a developer’s perspective. The only issue? There’s no real way to enforce that a plugin is only a Filter, Map, or Fold. Someone could mix them up, and things could get messy.

Rust Doesn’t Do That

Rust doesn’t have inheritance, nor does it let you implement traits for traits, like this:

trait Plugin {
  fn handle_records(&self, input: Vec<String>) -> Vec<String>;
}

trait Filter {
  fn filter(&self, input: Vec<String>) -> Vec<String>;
}

impl Plugin for Filter {
  fn handle_records(&self, input: Vec<String>) -> Vec<String> {
    self.filter(input)
  }
}

That just doesn’t work. Rust traits don’t stack like that.

I tried getting around this with generics:

trait Plugin {
  fn handle_records(&self, input: Vec<String>) -> Vec<String>;
}

trait Filter {
  fn filter(&self, input: Vec<String>) -> Vec<String>;
}

impl<X> Plugin for X 
where
  X: Filter
{
  fn handle_records(&self, input: Vec<String>) -> Vec<String> {
    self.filter(input)
  }
}

That worked… until I needed another command:

trait Map {
  fn map(&self, input: Vec<String>) -> Vec<String>;
}

impl<X> Plugin for X 
where
  X: Map
{
  fn handle_records(&self, input: Vec<String>) -> Vec<String> {
    self.map(input)
  }
}

Suddenly, Rust yelled at me:

sdk on  main [?] is 🦀 v1.86.0-nightly took 12s
❯ cargo build
   Compiling sdk v0.1.0 (/Users/angelo/sdk)
error[E0119]: conflicting implementations of trait `Plugin`
  --> src/main.rs:22:1
   |
9  | / impl<X> Plugin for X
10 | | where
11 | |   X: Filter
   | |___________- first implementation here
...
22 | / impl<X> Plugin for X
23 | | where
24 | |   X: Map
   | |________^ conflicting implementation

For more information about this error, try `rustc --explain E0119`.
error: could not compile `sdk` (bin "sdk") due to 1 previous error

Rust was absolutely correct – I couldn’t guarantee that Map and Filter wouldn’t be implemented on the same type. And Rust really cares about safety.

The Solution: Enum-based Plugins

After a bit of soul-searching, I landed on this approach:

type Records = Vec<String>;
pub enum PluginType {
  Filter(Box<dyn Filter>),
  Map(Box<dyn Map>),
}

pub trait Plugin {
  fn handle_records(&self, input: &Records) -> Result<Records>;

  fn run() {
    //...
  }
}

impl Plugin for PluginType {
  fn handle_records(&self, input: &Records) -> Result<Records> {
    match self {
      PluginType::Filter(v) => v.filter(input),
      PluginType::Map(v) => v.map(records),
    }
  }
}

pub trait Filter {
  fn filter(&self, input: &Records) -> Records;
}

pub trait Map {
  fn map(&self, input: &Records) -> Records;
}


pub fn filter_plugin<F>(plugin: F) -> PluginType
where
  F: Filter + 'static
{
  PluginType::Filter(Box::new(plugin))
}

pub fn map_plugin<M>(plugin: M) -> PluginType
where
  M: Map + 'static
{
  PluginType::Map(Box::new(plugin))
}

And for the developer, it looks just as clean as the Python version:

use sdk::{Filter, filter_plugin};

struct Passthrough;
impl Filter for Passthrough {
  fn filter(&self, input: Vec<String>) -> Vec<String> {
    input
  }
}

fn main() {
  const plugin = filter_plugin(Passthrough);
  plugin.run();
}

The Best Part?

Not only does this keep the API as clean as the OOP version, but it also ensures mutual exclusivity between plugin types! A type cannot be both a Filter and a Map – Rust enforces this at compile time.

So while Rust might not have traditional inheritance, with a little creativity (and a few enums), you can still get a clean and safe design.

Comments

Discussion powered by , hop in. if you want.