在Rust中,如何根据字符串选择一个结构体?

4
问题陈述
我有一组结构体,A、B、C和D,它们都实现了一个特质Runnable。
trait Runnable {
    fn run(&mut self);
}
impl Runnable for A {...}
impl Runnable for B {...}
impl Runnable for C {...}
impl Runnable for D {...}

我还有一个名为Config的结构体,它用作构建ABCD实例的规范。
struct Config {
    filename: String,
    other_stuff: u8,
}

impl From<Config> for A {...}
impl From<Config> for B {...}
impl From<Config> for C {...}
impl From<Config> for D {...}

在我的程序中,我想解析一个Config实例,并根据filename字段的值构造一个ABCD,然后调用Runnable::run。应该通过逐个检查每个结构体与filename字符串进行匹配,并选择第一个与该字符串"匹配"的结构体。

天真的实现

这是一个天真的实现。

trait CheckFilename {
    fn check_filename(filename: &str) -> bool;
}
impl CheckFilename for A {...}
impl CheckFilename for B {...}
impl CheckFilename for C {...}
impl CheckFilename for D {...}


fn main() {
    let cfg: Config = get_config(); // Some abstract way of evaluating a Config at runtime.

    let mut job: Box<dyn Runnable> = if A::check_filename(&cfg.filename) {
        println!("Found matching filename for A");
        Box::new(A::from(cfg))
    } else if B::check_filename(&cfg.filename) {
        println!("Found matching filename for B");
        Box::new(B::from(cfg))
    } else if C::check_filename(&cfg.filename) {
        println!("Found matching filename for C");
        Box::new(C::from(cfg))
    } else if D::check_filename(&cfg.filename) {
        println!("Found matching filename for D");
        Box::new(D::from(cfg))
    } else {
        panic!("did not find matching pattern for filename {}", cfg.filename);
    };

    job.run();
}

这个可以工作,但是有一些代码味道。
  • 巨大的if else if else if else if else...语句在我看来很臭
  • 重复太多:用于检查文件名、打印所选结构类型以及根据配置构建实例的代码在每个分支中都是相同的,唯一的区别在于它们处理的结构类型不同。有没有办法将这种重复性抽象出来?
  • 非常容易出错:很容易因为未能将结构与谓词同步而在文件名字符串和结构之间出现映射错误;例如,写出以下代码:
    if D::check_filename(&cfg.filename) {
        println!("Found matching filename for D");
        Box::new(B::from(cfg)) // 开发人员错误:构造了一个B而不是D。
    }
    
    而编译器不会捕捉到这个错误。
  • 向程序添加新的结构体(例如EFG等)不太方便。它需要在主if else语句中为每个结构体添加一个新的分支。将结构体简单地添加到某种“主列表”中会更好。

有没有更优雅或习惯用语的方式来解决这些问题呢?
2个回答

8
由于转换消耗了Config,统一所有类型的逻辑的挑战在于您需要有条件地移动配置值以进行转换。标准库中有多种可能会消耗的函数,它们使用的模式是返回Result,在Err情况下返回可能已经消耗的值。例如,Arc::try_unwrap提取了Arc的内部值,但如果失败,则返回ArcErr变体。
在这里我们也可以做同样的事情,创建一个单一函数,如果文件名匹配,则生成适当的结构之一,但在错误时返回配置。
fn try_convert_config_to<T>(config: Config) -> Result<Box<dyn Runnable>, Config>
where
    T: Runnable + CheckFilename + 'static,
    Config: Into<T>,
{
    if T::check_filename(&config.filename) {
        Ok(Box::new(config.into()))
    } else {
        Err(config)
    }
}

然后你可以编写另一个函数,其中包含一个静态切片,用于特定实例化的函数,它可以按顺序尝试每个实例,直到成功为止。由于我们将配置移入每个加载器函数中,所以我们必须将其放回到Err情况中,以便下一次循环迭代可以再次移动它。
fn try_convert_config(mut config: Config) -> Option<Box<dyn Runnable>> {
    static CONFIG_LOADERS: &[fn(Config) -> Result<Box<dyn Runnable>, Config>] = &[
        try_convert_config_to::<A>,
        try_convert_config_to::<B>,
        try_convert_config_to::<C>,
        try_convert_config_to::<D>,
    ];

    for loader in CONFIG_LOADERS {
        match loader(config) {
            Ok(c) => return Some(c),
            Err(c) => config = c,
        };
    }

    None
}

这解决了你所有的问题:
- 不再有庞大的if-else链,只有一个循环。 - 代码重复消失了,因为`try_convert_config_to`只需一次实现所有类型的逻辑。 - 只要使用`try_convert_config_to`,就不可能意外地在不同类型上调用过程的两个部分(`check_filename`和`into`)。 - 要添加新类型,只需将新元素添加到`CONFIG_LOADERS`切片中。
Playground

优秀的解决方案。你能解释一下为什么在try_convert_config_to函数中,T需要具有'static特性约束吗? - undefined
1
因为 dyn Foo 隐式地具有 'static 约束,除非你指定一个,所以 Box<dyn Runnable> 等同于 Box<dyn Runnable + 'static>。因此,要将 Box<T> 转换为 Box<dyn Runnable>T 必须是 'static - undefined
1
澄清一下,“unless you specify one” 的意思是“除非你指定一个不同的生命周期”。例如,你可以为某个生命周期 'a 拥有 Box<dyn Runnable + 'a> - undefined

1
回答自己的问题,整理一下被采纳答案留下的一些小问题,并提供一些额外的解释和参考资料。特别感谢@cdhowie提供的被采纳答案和我解决问题所需的知识。

概述

通过重构一些东西,可以解决所有与天真实现相关的问题。关键是创建一个简单的可失败转换函数,如果文件名匹配,将一个Config值转换为Box<dyn Runnable>,但如果文件名不匹配,则在Err变体中返回Config值,以便下一个特化可以尝试。

定义要求

首先,让我们编译一个列表,列出每种类型必须满足的最低要求,以支持我们的应用程序:

  1. 必须是Runnable,因为我们打算在实例化后运行它。
  2. 必须是From<Config>,因为我们打算从Config创建一个实例。
  3. 必须有一个与之关联的名称,以便我们可以打印标识选定的类型的日志消息。
  4. 必须能够确定它是否与一个filename字符串"匹配"。

定义了这些要求后,我们定义一个Trait来强制执行它们:

trait Job: Runnable + From<Config> {
    const NAME: &'static str;
    fn check_filename(filename: &str) -> bool;
}

要求1和2使用supertraits进行编码,要求3使用associated constant进行编码。
注意:在问题陈述中,这个特质被命名为CheckFilename,但在这里被重新命名为Job,因为考虑到额外的要求,这似乎更合适。
为我们的每个结构实现Job非常简单。
impl Job for A {
    // This str will be the used as the name of the struct when printing the log message
    const NAME: &'static str = "A";
    fn check_filename(filename: &str) -> bool {
        // logic determining if a `str` matches a Job type goes here
        filename.starts_with('A')
    }
}
impl Job for B {...}
impl Job for C {...}
impl Job for D {...}

创建一个通用的转换函数
接下来,我们创建一个简单的可失败函数,尝试将一个 `Config` 值转换为 `Box`,但如果不匹配,则将 `Config` 值返回(带有所有权)。这个函数适用于所有的 `Job` 类型。
fn try_convert_config_to<T: Job + 'static>(cfg: Config) -> Result<Box<dyn Runnable>, Config> {
    if T::check_filename(&cfg.filename) {
        println!("Found matching job for filename `{}`: {}", cfg.filename, T::NAME);
        Ok(Box::<T>::new(cfg.into()))
    } else {
        Err(cfg)
    }
}

将所有内容链接在一起
最后,可以通过调用`try_convert_config_to::`并通过一系列`Result::or_else`将错误值顺序传递给`Job`的剩余特殊化来初始化(带框的)`Runnable` trait对象。
fn main() {
    let cfg: Config = get_config();
    let mut job: Box<dyn Runnable> = try_convert_config_to::<A>(cfg)
        .or_else(try_convert_config_to::<B>)
        .or_else(try_convert_config_to::<C>)
        .or_else(try_convert_config_to::<D>)
        .map_err(|cfg| panic!("Couldn't find matching type for filename `{}`", cfg.filename))
        .unwrap();

    job.run();
}

游乐场

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接