'Using Traits to configure an application

I've been trying to figure out a good idiomatic configuration pattern in Rust. I read a few articles and the impression I'm getting is that Traits are a way to go for my use-case. However the examples were either too simplistic or too complex. But I went ahead an refactored my application but encountered some stuff I've never seen/understood. I marked the two locations in the code.

(a) - It took a minute to get this running as I kept dealing with compiler errors but finally tried &* assuming I was de-referencing the Box and then passing a reference to it. I'm not entirely sure this is correct and would like some insight into why it works and if it'll bite me later.

(b) - None of the articles/examples I read explained how to pass the configuration to other internal methods. Again I just kept adding symbols until the compiler was happy so I'm not too confident this is the right way.

On the pattern:

My example code's structure is very similar the actual application. I take the user's input then pass it to the App which delegates the creation of the data to Words::add() who delegates the creation of a Word to Word::new() and so on. In the actual application there's an additional layer or so where metadata is generated but in the end a each of these need access to the configuration. I started with a global static config which led to testing headaches, then moved to a concrete Config type which resulted in a massive struct dealing with all the different contexts.

My goal is to have an immutable configuration that can change based on context e.g. dev, testing, production etc. I'm assuming this is a big topic that might not have a simple answer but curious if there any insights into this. I was unable to find one that fits my use-case so I'm not 100% confident I'm going in the right direction.

Link to the playground.

Edit: Updated example to better represent actual application.

// Trait

pub trait Configuration: std::fmt::Debug {
    fn change_case(&self, s: &str) -> String;
    fn add_suffix(&self, s: &str) -> String;
}

// App

#[derive(Debug)]
pub struct App {
    config: Box<dyn Configuration>,
    data: Words,
}

impl App {
    pub fn new(config: Box<dyn Configuration>) -> Self {
        Self {
            config,
            data: Words::default(),
        }
    }

    pub fn add(&mut self, word: &str) {
        self.data.insert(&*self.config, word);
        // (a) This -----^^
    }
}

#[derive(Debug, Default)]
struct AppConfig;

impl Configuration for AppConfig {
    fn change_case(&self, s: &str) -> String {
        s.to_lowercase()
    }
    
    fn add_suffix(&self, s: &str) -> String {
        format!("{s}able")
    }
}

// Words

#[derive(Debug, Default)]
struct Words(Vec<Word>);

impl Words {
    fn insert(&mut self, config: &dyn Configuration, word: &str) {
        // (b) This -------------^^^^

        let word = Word::new(config, word);

        println!("Adding {:?}", word);

        self.0.push(word);
    }
}

#[derive(Debug, PartialEq)]
struct Word(String);

impl Word {
    pub fn new(config: &dyn Configuration, word: &str) -> Self {
        let word = config.add_suffix(word);
        let word = config.change_case(&word);
        Self(word)
    }
}

fn main() {
    let config = AppConfig::default();
    let mut app = App::new(Box::new(config));
    app.add("use");
    app.add("mark");
    app.add("seal");
}

#[cfg(test)]
mod tests {

    use super::*;

    #[derive(Debug, Default)]
    struct TestConfig;

    impl Configuration for TestConfig {
        fn change_case(&self, s: &str) -> String {
            s.to_uppercase()
        }
        
        fn add_suffix(&self, s: &str) -> String {
            format!("{s}ing")
        }
    }

    #[test]
    fn test() {
        let config = TestConfig::default();
        let mut app = App::new(Box::new(config));
        app.add("test");

        assert_eq!(app.data.0[0], Word("TESTING".to_string()));
    }
}


Solution 1:[1]

The point of traits is to enable polymorphism. If your configuration is just plain old data, then you probably don't need polymorphism: structs and enums would be enough.

If parts of your configuration involves changing behavior, then you might consider defining a trait for each behavior, defining each behavior using a function/closure, or in some cases an enum might be simpler to deal with. Aggregate these with the plain old data elements in a struct.

My goal is to have an immutable configuration

When you pass a shared reference around, the object is basically frozen (unless the object itself uses interior mutability through e.g. RefCell or Mutex). In other words, shared references provide scoped immutability for free. You could even expose public fields on your struct and the callee wouldn't be able to mutate them.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Francis Gagné