前两天看了一个在 rustlang 中如何利用 generic 和 PhantomData 来让我们的 api 更加合理的视频, 当时看完就想写一篇相关内容的文章, 但是没有立即动手,一推迟,不出意外的忘了。这两天又接手了一个半成品的项目, 需要维护原有代码和添加新的特性, 看代码的时候感慨良多, 又想起了前两天看的那个视频, 觉得必须得写点什么了。
假设我们要创建一个密码管理器, 这个管理器有两种状态, 一种是锁定状态, 在锁定状态下我们可以添加新的密码; 一种是非锁定状态, 在非锁定状态下我们可以列出所有的密码; 在任何状态下, 我们都可以查看管理器的版本。
代码如下:
use std::collections::HashMap;
#[derive(Debug, Eq, PartialEq)]
enum State {
Locked,
Unlocked,
}
struct PasswordManager {
state: State,
passwords: HashMap<String, String>,
version: String,
}
impl PasswordManager {
fn new(version: &str) -> Self {
Self {
state: State::Unlocked,
passwords: HashMap::new(),
version: version.into(),
}
}
fn lock(&mut self) -> Result<(), String> {
if self.state == State::Locked {
return Err("cannot lock a locked manager".into());
}
self.state = State::Locked;
Ok(())
}
fn unlock(&mut self) -> Result<(), String> {
if self.state == State::Unlocked {
return Err("cannot unlock a unlocked manager".into());
}
self.state = State::Unlocked;
Ok(())
}
fn add_password(&mut self, username: &str, password: &str) -> Result<(), String> {
if self.state == State::Unlocked {
return Err("cannot add password unless lock this manager".into());
}
self.passwords.insert(username.into(), password.into());
Ok(())
}
fn all_passwords(&self) -> Result<&HashMap<String, String>, String> {
if self.state == State::Locked {
return Err("cannot get all passwords unless unlock this manager".into());
}
Ok(&self.passwords)
}
fn version(&self) -> &str {
&self.version
}
}
在如上的代码里我们定义了一个PasswordManager
类型, 其内部通过维护一个State
字段来实现状态的切换, 这样实现了我们的需求, 但是在实际使用的时候如果不对这些方法进行说明的话,可能会对用户造成困惑。比如如下的代码:
fn password_manager_demo() {
let mut m = PasswordManager::new("0.1");
// 未调用lock(), 会返回Error
m.add_password("Marquez", "93");
// 对一个unlocked的manager调用unlock(), 会返回Error
m.unlock();
m.lock();
// all_passwords()只能在unlocked的状态下调用
println!("{:?}", m.all_passwords());
}
以上代码我把应有的异常检查部分省略掉了, 有的同学可能会说, 加上异常检查不就可以了么, 但是异常检查是运行时的检查, 对编译时没有任何作用, 也就是说, 如果不运行这些代码, 我们不会知道这里面有没有错误。编译器不会给出任何警告, 自动补全也会列出所有的方法
既然这样不好用, 我们就改一下:
struct LockedPasswordManager {
passwords: HashMap<String, String>,
version: String,
}
struct UnlockedPasswordManager {
passwords: HashMap<String, String>,
version: String,
}
impl LockedPasswordManager {
fn new(version: &str) -> Self {
Self {
passwords: HashMap::new(),
version: version.into(),
}
}
fn unlock(self) -> UnlockedPasswordManager {
UnlockedPasswordManager {
passwords: self.passwords,
version: self.version,
}
}
fn add_password(&mut self, username: &str, password: &str) {
self.passwords.insert(username.into(), password.into());
}
fn version(&self) -> &str {
&self.version
}
}
impl UnlockedPasswordManager {
fn new(version: &str) -> Self {
Self {
passwords: HashMap::new(),
version: version.into(),
}
}
fn lock(self) -> LockedPasswordManager {
LockedPasswordManager {
passwords: self.passwords,
version: self.version,
}
}
fn all_passwords(&self) -> &HashMap<String, String> {
&self.passwords
}
fn version(&self) -> &str {
&self.version
}
}
这次我们把PasswordManager
直接分成了两个类型, LockedPasswordManager
和UnlockedPasswordManager
, 不用内置的字段来维护状态了, 两种类型本身就代表了不同的状态, 这种方法是我平时写 go 的比较常用的方法, 这样两种类型各自实现自己的方法, 不小心调用了对方的方法,编译器会报错, 而且自动补全也只会列出响应状态的方法了。
看上去好了很多, 但是这也引入了两个问题
-
我们的
lock()
和unlock()
方法接受的参数从&mut self
变成了self
, 当然你也可以选择不换, 但最终这两个方法都需要返回一个新的 object, 因为现在状态是靠不同类型的 struct 来实现的 -
version()
方法我们要分别在两种类型里各实现一遍,这就犯了代码不能复用的忌讳, 因为如果未来要修改这个方法, 我们需要提醒维护者要分别在这两个类型中进行修改。一旦忘了一个, 那本该相同的返回值就可能会变得不同了
对于第一个问题, 我们无能为力, 这是分开类型的必然结果, 但是如果我们从函数式编程的角度来看, 这种问题就不叫问题了, 这正是他们所鼓励的不可变变量的形式, 我们不改变变量的值, 而应该将它输入到一个函数中然后获得一个新的符合我们要求的变量。
第二个问题确实是一个比较严重的问题, 在 go 里, 如果遇到这种情况, 我通常会将version()
单独封装在一个 struct 里, 然后以匿名字段的形式将这个 struct 放到LockedPasswordManager
和UnlockedPasswordManager
中, 这样即不会影响version()
的调用又不会出现代码不能复用的问题。
那 rust 呢?今天的主题终于来了, 那就是利用 generic 和 PhantomData 来解决这个问题, 上代码:
use std::marker::PhantomData;
struct Locked;
struct Unlocked;
struct GenericPasswordManager<S = Unlocked> {
passwords: HashMap<String, String>,
version: String,
state: PhantomData<S>,
}
impl<S> GenericPasswordManager<S> {
fn new(version: &str) -> Self {
Self {
passwords: HashMap::new(),
version: version.into(),
state: PhantomData,
}
}
fn version(&self) -> &str {
&self.version
}
}
impl GenericPasswordManager<Unlocked> {
fn lock(self) -> GenericPasswordManager<Locked> {
GenericPasswordManager {
passwords: self.passwords,
version: self.version,
state: PhantomData,
}
}
fn all_passwords(&self) -> &HashMap<String, String> {
&self.passwords
}
}
impl GenericPasswordManager<Locked> {
fn unlock(self) -> GenericPasswordManager<Unlocked> {
GenericPasswordManager {
passwords: self.passwords,
version: self.version,
state: PhantomData,
}
}
fn add_password(&mut self, username: &str, password: &str) {
self.passwords.insert(username.into(), password.into());
}
}
先说一下 PhantomData, 这货其实跟它的名字一样, 你说它存在, 你看不到它也摸不到它, 你说它不存在, 它还确实以自己的方式对外界施加着影响。
Zero-sized type used to mark things that “act like” they own a
T
.
Adding aPhantomData<T>
field to your type tells the compiler that your
type acts as though it stores a value of typeT
, even though it doesn’t
really. This information is used when computing certain safety properties.
以上是官方文档里对它的解释, 其实就是个 size 为 0 的 struct, 或者说是个 type 标识, 目的就是为了防止编译器 bb, 你引入的 generic 变量没有在 struct 中使用到。有了它, 我们就可以新定义两个 struct, Locked
和Unlocked
, 然后再把它们以PhantomData
的形式放到我们的GenericPasswordManager
中, 这样我们就得到了两种类型GenericPasswordManager<Locked>
和GenericPasswordManager<Unlocked>
, 同时我们还可以以impl<S> GenericPasswordManager<S>
的形式来实现实现两种类型共用的version()
问题到此告一段落,但是回到开头的位置,看着最初的那段代码,不禁思考,真的有必要这样做吗?
有必要,很有必要,我们写代码就像在讲故事,怎样能把故事讲的条理清晰,没有歧义其实往往比故事本身的精彩程度要重要的多。就一套 API 而言,如果你能让你大部分的用户在不看文档不看代码的情况下,单看函数签名就可以用的得心应手,那这一定是一套高质量的 API,而你也一定是一位经验丰富的程序员。写的代码越多,越觉得什么数据结构和算法,什么设计模式和架构,这些看上去高大上的东西其实只占很小的比重,甚至这些你都不了解,大部分的情况下仍然不妨碍你写出高质量的代码。写代码真正难的是如何把代码写的符合大多数人的直觉, 这里的人可能是一个线上产品的最终用户,也可能是搞二次开发的开发者,也可能是你的这段代码的后续维护者。不管你用什么样的方法,你能让他们在用你的代码的时候惊呼"想一块去了", 那你就是一个真正成功的开发者。但是,很多时候我们做不到这一点,有些现实当中的问题从根本上就是反直觉的,映射到代码中,我们也不得不做出妥协,但是妥协不意味着我们一点办法都没有,我们可以通过调整代码的结构,给变量起意义更明确的名字,写更多的注释,提供更详尽的文档, 来让使用者尽可能多的理解我们的用意。所以,代码写到最后,你会发现,那些当年你觉得不入流的"手段",其实才是码农这一行当的精髓。