Newshot
Account

写给凡人的 ML 模块入门

Translation(model translation, not the source's own text)

我认为大家都会同意代码需要模块化。模块化让我们无论代码变得多大,都能在局部对代码进行推理。然而,似乎没有人对代码应该如何模块化达成一致。有很多不同的方法,而且每门新语言似乎都会带来自己的诠释。

今天我们要讨论一种相当古老的诠释:ML 模块。这里的 ML 代表 Meta Language,如 SML 和 OCaml 中所示(不是机器学习)。尽管 SML 最早出现于 1983 年,ML 模块并未流行起来。OCaml 是你能找到的唯一带有 ML 模块系统的生产级语言,而即便在其最流行的时候,它也属于小众。

这很遗憾,因为 ML 模块能提供很多东西。我们打算谈谈 ML 模块是什么,然后稍微涉及一点 ML 模块为什么存在。毕竟,还有谁比一个不知道模块里有什么的人更适合解释模块呢?……转念一想,别回答那个问题。……我已经把这篇博客写完了。

模块的存在是为了让我们的代码模块化,这还用说,但这在实践中意味着什么?我认为大多数程序员对模块化都有某种直觉:

  • 将相关逻辑打包成一个“东西”
  • 封装不变量
  • 与依赖解耦

大多数语言都有办法做到这一点,如果没有,就会有人为它们创造一个(说的就是你,JavaScript)。

模块打包函数 链接到标题

ML 模块通过——你猜对了——Module 来实现这一点。然而,ML 模块与我们熟知且喜爱的模块有些不同。为了解释它们的区别,我们会借助一种虚构的、受 Rust 启发的语法。它一开始看起来会有点奇怪,随着我们引入各种概念,最终会变得非常奇怪(但希望比 SML 语法更熟悉)。我们先从一个只包含函数的模块开始:


        mod AModule {
  fn add(a: i32, b: i32) -> i32 {
    a + b
  }

fn sub(a: i32, b: i32) -> i32 {
    a - b
  }

// mult, div, etc...
}
      

AModule 把相关的算术函数打包成一个我们可以推理的整体。每个函数都能看到模块中的其他每个函数。这很有用,但任何带有命名空间的语言都能提供这一点。

模块(也)打包类型 链接到标题

稍微更独特的是,ML 模块不仅允许我们把函数定义放进模块,还允许放入类型定义:


        mod Set {
  type Set = Vec<i32>;

fn new() -> Set {
    vec![]
  }

fn index_of(this: &Set, ele: &i32) -> Result<usize, usize> {
    this.binary_search(ele)
  }

fn insert(this: Set, ele: i32) -> Set {
    match index_of(&ele) {
      Ok(_) => {} // we're done the element is already present
      Err(indx) => {
        this.insert(indx, ele);
      }
    }
    this
  }

fn contains(this: &Set, ele: &i32) -> bool {
    index_of(this, ele).is_ok()
  }

fn remove(this: Set, ele: &i32) -> Set {
    match index_of(this, ele) {
      Err(_) => {}, // we're done the element is already removed
      Ok(indx) => {
        this.remove(indx)
      }
    }
    this
  }
}
      

这里我们有一个 Set 模块,其中有一个 Set 类型,它是排序后的 Vec<i32>。我们模块中的函数可以引用类型定义,就像它们可以引用所在模块的函数定义一样。好吧,但这还不算太疯狂,仍然完全在 Rust 模块的范畴内。

模块的类型是签名 链接到标题

模块不仅能包含类型定义。模块本身也有类型。模块类型被称为签名(这样我们就不会把它与“普通”类型混淆)。我们 Set 模块的签名是:


        sig FullSetSig {
  type Set = Vec<i32>;

fn new() -> Set;
  fn index_of(&Set, &i32) -> Result<usize, usize>;
  fn insert(Set, i32) -> Set;
  fn contains(&Set, &i32) -> bool;
  fn remove(&Set, &i32) -> Set
}
      

文章标题:写给凡人的 ML 模块

正文: 模块签名声明了一个模块的每个定义及其类型。这看起来可能类似于我们在 Rust 中定义 trait 的方式。这是因为,正如 trait 在 Rust 中充当接口,签名在 ML 模块中充当接口。模块拥有签名是理解 ML 模块最重要的一件事。模块的全部复杂性与表达力,都源于它们拥有签名以及由此带来的种种奇妙古怪的可能。

签名支持封装 链接到标题

签名类似于 C/C++ 实现文件遵循其头文件的方式。与 .cc 文件不同,模块与签名并非一一对应的关系。我们的 Set 模块可以遵循任何它匹配的签名。当一个模块定义了签名中的每个声明且它们在类型上一致时,该模块就匹配该签名。

匹配签名提供了封装,将模块的内部细节从我们的公共 API 中隐藏起来。看看 FullSetSig。Set 的调用者真的需要知道 index_of 吗?封装的支持者会大声喊出:不。index_of 是 Set 的实现细节,Set 是 Vec<i32>,并非我们 Set API 不可或缺的部分。

我们可以通过让 Set 模块匹配一个新签名来封装 index_of:


        sig SetSig {
  type Set = Vec<i32>;

fn new() -> Set;
  // index_of 缺失。
  fn insert(Set, i32) -> Set;
  fn contains(&Set, &i32) -> bool;
  fn remove(&Set, &i32) -> Set
}
      

SetSig 移除了 index_of,隐藏了实现细节。然后我们可以把签名归属到我们的 Set 模块:


        mod Set : SetSig {
    // 我们的模块实现保持不变……
}
      

现在代码的其余部分将 Set 视为没有 index_of 的 SetSig。重要的是,我们的 Set 模块并没有改变。index_of 在模块内部仍然可用。归属签名只改变了模块的公共视图,类似于 Rust 中的私有函数。

模块的定义并不是我们唯一可以归属签名的地方。我们还可以通过让旧模块匹配新签名来创建一个“新”模块。假设我们想要一个 InsertOnlySet(别管我为什么需要它):


        sig InsertOnlySetSig {
  type Set = Vec<i32>;

fn new() -> Set;
  fn insert(Set, i32) -> Set;
}
      

我们可以纯粹通过匹配签名从我们的 Set 模块创建它:


        mod InsertOnlySet: InsertOnlySetSig = Set;
      

现在 InsertOnlySet 实际上是一个全新的模块,尽管底层实现与 Set 共享。这就是模块的酷之处。我们可以仅仅通过给模块一个不同的签名就“免费”地重新利用它们。如果我们想在 OO 语言中做到这一点,就必须在类定义中添加一个新接口。但我们可以在代码库的任何地方随手拍上这么一个家伙。

密封类型封住封装 链接到标题

将签名归属到模块让我们可以隐藏实现细节,但这里有一个相当明显的漏洞。我们确实隐藏了 index_of,但我们的模块向全世界广播了类型 Set 是 Vec<i32>!Set 依赖于我们的向量保持有序,但任何人都可以随意写一个函数:


        fn whoops() {
    let mut set = Set::new();
    // 嘻嘻
    set.extend([3, 2, 1]);
    // 💥
    let uh_oh = Set::insert(set, 1);
    // 现在我们的集合是 [1, 3, 2, 1]
}
      

这对我们的封装来说不过是点皮外伤。幸运的是,ML 模块预见到了这个问题,并提供了一种使用签名的解决方案。

正如签名让我们可以隐藏函数,它也让我们可以隐藏类型。ML 模块称之为抽象类型。如果我们把 SetSig 更新为:


        sig SetSig {
  type Set;

fn new() -> Set;
  fn insert(Set, i32) -> Set;
  fn contains(&Set, &i32) -> bool;
  fn remove(&Set, &i32) -> Set
}
      

我们签名不再广播 type Set 就是 Vec<i32>。在 Set 模块内部,我们可以自由地把 type Set 当作 Vec<i32> 来用,但在模块之外的任何地方,type Set 现在都是一个未知类型。因为调用方对我们这个新抽象出来的 Set 类型一无所知,他们能做的只是把它传给 Set 模块提供的方法、或从这些方法接收它。只要我们在 Set 内部保持底层的 Vec<i32> 有序,我们就知道没有别人能改动它。他们对这个类型了解得实在太少!

模块版的泛型 Link to heading

我们的 Set 模块已接近完美,但还差那么一点点:它只能用于 i32。当然,每次需要一个新版本时,我们可以写一个新的模块 SetU64、SetString,如此无穷无尽,但是拜托,总该有更好的办法吧。这又不是 Go。

泛型是解决这个问题的首选方案。我们甚至自己就用过 Vec<i32> 的泛型。不过,ML Modules 有一种更模块化的解决方案。你可以用一个模块来参数化另一个模块。

把一个参数化模块想象成一个函数:它接受一个模块,返回另一个模块。为了让我们的 Set 能接受任意类型而不只是 i32,我们用一个提供 Set 元素类型的模块来参数化它:


        mod Set: SetSig (sig Elem {
  type T
}) {
  type Set = Vec<Elem.T>;

fn new() -> Set {
    vec![]
  }

fn index_of(this: &Set, ele: &Elem.T) -> Result<usize, usize> {
    // ...
  }

fn insert(this: Set, ele: &Elem.T) -> Set {
    // ...
  }

fn contains(this: &Set, ele: &Elem.T) -> bool {
    index_of(this, ele).is_ok()
  }

fn remove(this: Set, ele: &Elem.T) -> Set {
    // ...
  }
}
      

我们这个新参数化的 Set 接受任何符合 Elem 的模块。任何模块只要至少有一个 T 定义,就能匹配 Elem。

一个小小的麻烦 Link to heading

这样一来,我们的 Set 模块就支持任意类型了。只除了一点小细节。我用一些战术性的 // ... 骗了你。如果我们真的去实现 index_of,很快就会遇到一个问题:


        fn index_of(this: &Set, ele: &Elem.T) -> Result<usize, usize> {
  this.binary_search_by(|a| ele ??? a)
}
      

我们不知道该如何比较 T。Rust 的 Ord 当然能解决这个问题,但 ML modules 有模块,而不是 trait。不过 Set 接收的不只是一个类型,它接收的是一个完整的模块。如果我们需要知道如何比较 T,我们直接问我们的 Elem 模块就行了:


        mod Set: SetSig (sig Elem {
  type T;
  fn cmp(&T, &T) -> Ordering;
}) {
  // ...
}
      

现在如何实现 index_of 就一目了然了:


        fn index_of(this: &Set, ele: &Elem.T) -> Result<usize, usize> {
  this.binary_search_by(|a| Elem::cmp(ele, a))
}
      

有了参数化模块的实现,我们就可以通过把它应用到新的参数模块上来创建新的 Set 模块:


        mod SetU32 = Set(mod U32 {
  type T = u32;
  fn cmp(a: &T, b: &T) { a < b }
});
      

SetU32.Set 就是一个 Vec<u32>,并提供一个接收和返回 u32 而非 i32 的接口。我们可以同样轻松地再来一次:


        mod SetString = Set(mod String {
  type T = String;
  fn cmp(a: &T, b: &T) { /* lexographic ordering probably... */ }
});
      

就这样,一个全新的模块。这次是一个 Strings 的集合。我可以这样玩一整天。

因为我们接收的是一个模块,所以我们可以请求模块能容纳的任何东西(甚至可以是另一个完整的子模块或参数化模块!)。缺点是我们每次都得指定整个模块。每次应用我们的 Set 模块时,我们都得指定 cmp。Rust 的 trait 让我们可以为一个类型写一次 Ord,然后在所有地方隐式使用。

include 让模块保持小巧 Link to heading

在实践中,我们其实并不会每次想要一个 Set 时都重复说明如何排序我们的类型。回想一下,一个签名会匹配任何提供了它所需内容的模块。如果我们做一个叫 U64 的大模块:


        mod U64 {
  type T = u64;

fn cmp(a: &u64, b: &u64) { a < b }

fn add(a: u64, b: u64) -> u64 { a + b }
  fn sub(a: u64, b: u64) -> u64 { a - b }
  // ...
}
      

那我们就可以把它作为 Set 的参数:


        mod SetU64 = Set(U64);
      

但等等,我们进入整个模块这摊事,是为了拆分代码。现在却得把所有东西都倒进一个模块里,以防以后需要?这不太符合我们模块化省钱的风格。

ML 模块还有最后一个特性,能让我们两全其美:include。include 会把一个模块内联展开到另一个模块中。我们可以自由地把 U64 模块拆成各种子模块:U64Order、U64Arithmetic 等等。最终的 U64 模块只需要 include 这些子模块:


        mod U64 {
  type T = u64;

include U64Order;
  include U64Arithmetic;
  // ...
}
      

模块可以被组织成逻辑组件,之后再组合起来以便使用(哇!)。我们可以随心所欲地切分并重新组装模块。我们甚至可以应用一个模块,并将它内联 include:


        mod ABunchOfSetForSomeReason {
  mod SetU64 = Set(U64);
  include SetU64;

mod SetString = Set(String);
  include SetString;
}
      

为什么你需要一个由一堆相互重叠的 Set 组成的模块?我不知道!我不是你的主管。不管怎样,模块会很乐意让你这么做。

把它们组合起来 标题链接

我们已经涵盖了模块的基础知识。让我们看一个更复杂的例子,感受一下模块能提供什么。我们将用模块组装一个小型构建执行器。我们的构建步骤将是一个参数化模块:


        mod BuildStep(sig Env {
  mod Cache: CacheSig;
  mod Runner: RunnerSig;
}) {
  // ...
}
      

其中 CacheSig 表示构建步骤的缓存,RunnerSig 表示运行某个命令:


        sig CacheSig {
  fn is_cached(inputs: &[String], output: &Path) -> bool;
  fn store(inputes: &[String], output: &Path);
}

sig RunnerSig {
  fn run(inputs: &[String], output: &Path);
}
      

为简洁起见,我们会让这些保持相当简单,但希望你能想象它们会如何扩展。然后我们可以根据输入模块来实现 BuildSet:


        mod BuildSet(...) {
  fn run_cached(inputs: &[String], output: &Path) {
    if !Env::Cache::is_cached(inputs, output) {
      Env::Runner::run(inputs, output);
      Env::Cache::store(inputs, output);
    }
  }

fn compile(src: String) -> String {
    let obj = PathBuf::from(format!("{src}.o"));
    run_cached(&[src], &obj);
    obj.display().to_string()
  }

fn build(srcs: &[String], output: &Path) {
    let objs: Vec<String> = srcs.iter().map(compile).collect();
    run_cached(objs, output)
  }
}
      

我们的构建函数接收一组源文件,编译它们,然后把目标文件链接成最终二进制文件。run 怎么判断什么时候在编译、什么时候在链接?别担心!既然已经设置好了,我们就可以通过控制传给 BuildStep 的模块来切换构建功能。对于真实构建,我们会想要类似内存缓存的东西:


        mod MemCache {
  // Pretend this works without locking for multithreading
  static ENTRIES: Vec<(Vec<String>, PathBuf)> = vec![];

fn is_cached(inputs: &[String], output: &Path) {
    ENTRIES.iter().any(|(cached_inputs, cached_outputs)| {
      inputs == cached_inputs && output == cached_outputs
    })
  }

fn store(inputs: &[String], output: &Path) {
    ENTRIES.push((inputs.to_vec(), PathBuf::from(output)))
  }
}
      

接下来我们会想要一个运行命令的东西:


        mod ShellRunner {
  fn run(inputs: &[String], output: &Path) {
    let cmd = // magically pick between `cc` and `lld`;
    let output = format!("-o {}", output.display());
    Command::new(cmd)
      .args(inputs)
      .args(output)
      .output()
      .expect("All our commands work first try");
  }
}
      

然后我们可以通过以下方式组装构建系统:


        mod CachedBuild = BuildStep(mod Env {
  mod Cache = MemCache;
  mod Runner = ShellRunner;
});
      

但也许我们并不总是想要缓存。也许我们想要一个在本地测试时总是重新运行操作的构建。我们只需换掉缓存模块:


        mod NoCache {
  fn is_cached(inputs: &[String], outputs: &Path) -> bool {
    false
  }
      

面向普通人的 ML 模块


        fn store(inputs: &[String], outputs: &Path) {
    // nothing to do here
  }
}

mod LocalBuild = BuildStep(mod Env {
  mod Cache = NoCache;
  mod Runner = ShellRunner;
});
      

我们的构建系统第一次就成功了。当然,我们不会把代码写得很糟,但以防万一,我们可能想测试它。FakeRunner 可以让我们做到这一点:


        mod FakeRunner() {
  static COMMANDS: Vec<(Vec<String>, PathBuf)> = vec![];

fn run(inputs: &[String], output: &Path) {
    COMMANDS.push((inputs.to_vec(), PathBuf::from(output)));
  }

// A test only helper.
  fn contains_command(inputs: &[String], output: &Path) -> bool {
    // ...
  }
}
      

因为 FakeRunner 是一个参数化模块,每次应用它时,我们都可以创建一个新模块(以及一个新的空 COMMANDS):


        mod Tests {
  mod TestRunner = FakeRunner();
  mod TestBuild = BuildStep(mod Env {
    mod Cache = MemCache;
    mod Runner = TestRunner
  });

// Write tests against TestBuild using `TestRunner::contains_command`...
}
      

如果我们想检查缓存是否有效,可以用一个新的假 runner 扩展测试,并比较它们的命令:


        mod Tests {
  // TestBuild setup...

mod UncachedTestRunner = FakeRunner();
  mod UncachedTestBuild = BuildStep(mod Env {
    mod Cache = NoCache;
    mod Runner = UncachedTestRunner;
  })

// Compare the results of `TestRunner::contains_command` and `UncachedTestRunner::contains_command`...
}
      

这并不是什么惊天动地的东西。然而,如果我们想在 OO 语言中复制这种设置,就需要运行时对象,或者——但愿不会——一个 DI 框架。在这里,我们可以通过静态地连接模块来完成这一切。

希望这有助于你看到模块的酷之处。模块的力量在于它们的签名。在其他语言中需要 newtype 包装器或可见性更改的事情,只需改变查看模块时所依据的签名即可完成。

借助抽象类型,这允许构建封装的网络,其中每个部分都精确地知道它需要知道的关于其邻居的信息,仅此而已。签名充当这种封装边界,让我们能够以模块化的方式推理代码。只要我们的模块符合预期的签名,我们就可以按照最狂野的奇思妙想扭曲和重构它,并相信它会继续如预期般集成到我们的系统中。ML 模块很简洁,我认为它们值得在编程语言中获得更多的喜爱和探索。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗