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:
- The SDK should abstract away all protocol handling, exposing only the core data processing functionality.
- The setup should be minimal.
- The design should be self-explanatory.
- 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.