< 返回我的博客

是也乎 发表于 2023-03-09 17:37

Tags:大妈,快译,OOP

Rust 超越面向对象,第2部分

原文:Rust Is Beyond Object-Oriented, Part 2: Polymorphism :: The Coded Message

快译

在这篇文章中, 通过讨论 OOP 三大传统支柱中的第二个: 多态, 继续系列文章:关于 Rust 和传统 OOP 范式的不同;

多态性是面向对象编程中的一个特别重要的话题, 也许是其三大支柱中最重要的一个; 关于多态性是什么, 各种编程语言如何实现(在 OOP 世界内外---是的,多态性也存在于 OOP 宇宙之外), 如何有效的使用, 以及更加关键的何时嫑使用; 可以写一些关于如何单独使用多态的 Rust 版本的书了;

不幸的是, 这只是一篇 blog,所以, 我无法像我想的那样详细或是多样性的介绍多态; 相反,我将特别关注 Rust 和 OOP 概念的不同之处; 我将从描述其在 OOP 中的工作方式开始, 然后, 讨论如何在 Rust 中实现相同的目标;

在 OOP 中,多态性就是一切; 试图采取所有决策(或是尽可能多的决策)并将其统一在一个通用的狭义机制中: 运行时多态; 但是, 不幸的是, 并不是任意运行时多态, 而是一种特定的/狭义的运行时多态形式, 受到 OOP 哲学和实现如何工作细节的限制:

  • 间接需求: 每个对象通常都必须存储在堆上,才能使运行时多态生效, 因为,不同的"运行时类型"具有不同的尺寸; 这鼓励了可变对象的别名使用;不仅如此,要真正调用一个方法,必须穿过三层间接:
    • 解引用对象引用
    • 解引用类指针或是 “vtable” 指针
    • 最后完成间接函数调用
  • 排斥优化: 除了间接函数调用的内在成本之外, 调用是间接的这一事实, 意味着内联是不可能的;通常,多态方法很小,甚至于微不足道,例如返回常量/设置字段或是重新排列参数并调用另一个方法, 因此, 内联会很有用; 内联对于允许优化跨内联边界也很重要;
  • 仅能在单一参数上多态: 特殊的接收者参数,称为 self 或是 this, 是运行时多态也的并上通常可能通过的唯一参数; 其它参数的多态可以用那些类型中的辅助方式来模拟, 这就很尴尬, 而且, 返回类型的多态也是不可能的;
  • 每个值都是独立多态的: 在运行时多态中, 通常没有办法说集合的所有元素都属于实现相同接口/interface 的某种类型 T,但是, 该类型是什么又应该在运行时能确定;
  • 和其它 OOP 特性纠缠在一起: 在 C++ 中,运行时多态和继承紧密耦合; 在很多 OOP 语言中, 多态仅适用于类的类型, 正如我在上篇 blog 中讨论的那样, 类类型是一种受约束的模块形式;

其实我完全可以针对以上每条吐糟单独写一大篇文章 --- 也许有一天真的会;

不过,尽管有这么多限制, 多态仍然被视为使用 OOP 语言进行决策的首选方式, 并且, 特别直观且易于访问; 受过训练的程序员, 嘦可能就一定使用此工具, 无论是否是手上决策是最佳工具, 即便当前不需要用多态进行运行时决策; 有些编程语言,例如 Smalltalk 甚至折叠了 "if-then" 逻辑, 并循环到 this 这个奇怪的特定决策结构中, 通过多态方法(如: ifTrue:idFalse)最终实现, 这些方法将在 True 和 False 类中以不同方式再实现 (和 therefore 在 true 以及 false 对象上配套);

需要明确的是, 拥有基于 vtable 的运行时多态性机制本身并不是一件坏事儿 --- Rust 甚至有也一个 (和上述 OOP 版本相似,但是,并不完全对等); 但是, Rust 版本只用以相对罕见的情况, 在这种情况中, 该机制最适合整个儿 palette 机制; 在 OOP 中, 将这种严格约束且忾和用低下的决策制定形式提升到所有其它形式之上, 以及使用多态是表达程序注释和业务编辑的最佳方式以及最直观方式的哲学断言, 本身就是个问题;

事实证明,当你选择最适合手头情况的工具时, 编程更加吻合人体工程学 --- 而 OOP 运行时多态性, 只是偶尔才是最合适完成当前工作的实效工具;

因此, 让我们看看在 OOP 使用运行时多态性时, 可以使用的 Rust 版四种替代方案;

备选方案#0:枚举

不仅有其它形式的多态性, 而且具有更少的严格约束 (例如 Haskell 的类型类)或一组不同的权衡 (例如 Rust 的 trait,主要基于 Haskell 类型类), Rust 中还有另外一个决策系统, 即:代数数据类型(ADTs, algebraic data types) 或曰求合/sum 类型, 也能接管 OOP 样式多态的很多应用程序;

在 Rust 中, 这些被称为 枚举/enums; 很多编程语言中的枚举是存储在整数尺寸类型中的常量列表, 有时以类型安全的方式实现(比如在 Java 中), 有时不是(比如在 C 中), 有时可以使用任何一种选项(比如,在 C++ 中枚举和枚举类之间就有区别);

Rust 枚举支持这种熟悉的用例, 而且具有类型安全:

pub enum Visibility {
    Visible,
    Invisible,
}

但是, 还支持和每个选项关联的附加字段, 创建类型理论中称为"总和类型"(sum type)的东西, 但在 C 或是 C++ 程序员中更加广为人知识的叫"联合标记"(tagged union) --- 不同之处在于, Rust 中, 编译器知道并能强制执行标记;

以下是一些枚举声明的示例:

pub enum UserId {
    Username(String),
    Anonymous(IpAddress),
    // ^^ This isn't supposed to be a real network type,
    // just an example.
}

let user1 = User::Username("foo".to_string());
let user2 = User::Anonymous(parse_ip("127.0.0.1")?);

pub enum HostIdentifier {
    Dns(DomainName),
    Ipv4Addr(Ipv4Addr),
    Ipv6Addr(Ipv6Addr),
}

pub enum Location {
    Nowhere,
    Address(Address),
    Coordinates {
        lat: f64,
        long: f64,
    }
}

let loc1 = Location::Nowhere;
let loc2 = Location::Coordinates {
    lat: 80.0,
    long: 40.0,
};

你可能会问,这些联合标记和多态有什么关系? 好吧, 大多数 OOP 语言对于这些 求合类型/sum type 没什么好办法, 但是, 她们确实有强大的运行时多态机制, 所以, 你会看到运行时多态用 Rust 枚举实现也是一样的适合 (我可能进一步争辩: 更加合适): 每当有一些小关于如何协商会议值的选项, 但是,这些选项又包含不同细节时;

比如, 这是一种使用继承和运行时多态在 Java 中表示 UserId 类型的方法 --- 当我还是学生时, 肯定会这么来(将每个类放在不同的文件中):

class UserId {
}

class Username extends UserId {
    private String username;
    public Username(String username) {
        this.username = username;
    }

    // ... getters, setters, etc.
}

class AnonymousUser extends UserId {
    private Ipv4Address ipAddress;
    
    // ... constructor, getters, setters, etc.
}

UserId user1 = new Username("foo");
UserId user2 = new AnonymousUser(new Ipv4Address("127.0.0.1"));

重要的是, 就像在枚举示例中一样, 我们可以将 user1 和 user2 给定相同类型的变量, 并将她们得狮给相同类型的函数, 并通常对她们执行相同的操作;

现在这些 OOP 风格的类看起来轻飘到飞溅的程度, 但是, 这主要是因为我们没有为这种情况添加任何真正的操作代码 --- 只有数据和结构, 以及一些变量定义和模板; 让我们考虑一下, 如果我们真的对用户 ID 尝试进行任何操作时会怎么样?

例如,我们可能想确认她们是否为管理员; 在我们的假设中, 假设匿名用户永远不是管理员, 而拥有用户名的用户只有在用户名以字符串 admin_ 开头时,才是位管理员;

理论上认可的 OOP 方法是添加一个方法, 比如: administrator; 为了让这个方法起作用, 我们必须将其追加到所有三个类: 基数以及两个子类:

class UserId {
    // ...
    public abstract bool isAdministrator();
}

class Username extends UserId {
    // ...
    public bool isAdministrator() {
        return username.startsWith("admin_");
    }
}

class AnonymousUser extends UserId {
    // ...
    public bool isAdminstrator() {
        return false;
    }
}

因此, 为了在 Java 中为这种类型添加这种简单的操作,如此简单的能力, 我们却必须使用三个类, 而且必须存储在三个文件中; 丫们每个对象都包含一个方法来作一些简单的事儿, 但是, 在任何羌族都看不到谁是管理员, 又或者不是管理员的完整逻辑 --- 有人可能会很不合时宜的问出这个问题;

Rust 则为这种操作使用 match, 将有关所有信息放在一个地方完成判定:

fn is_administrator(user: &UserId) -> bool {
    match user {
        UserId::Username(name) => name.starts_with("admin_"),
        UserId::AnonymousUser(_) => false,
    }
}

诚然, 这将产生更加复杂的单个函数, 但是,具有明确的所有逻辑; 让编辑明显而不是隐含在继承屡次结构中, 这就违反了 OOP 原则, 在 OOP 宇宙中, 方法应该简单, 多态性用于隐含的表达逻辑; 但是,这并不能保证任何事儿, 只是将其扫到地毯下而已: 事实证明, 隐藏复杂性会令其更难应对, 而不是相反;

让的我们来看另外一个例子; 我们已经用了一段时间的 UserId 代码, 你的任务是为这个系统编写一个新的 Web 前端; 你需要某种方式, 以 HTML 格式显示用户信息, 要么是指向用户配置文件的链接(对于指定用户), 要么是将 IP 地址字符串化为红色(对于匿名用户); 因此, 你决定为这个小型类型追加一个新操作 toHTML, 将输出新前端的专用 DOM 类型; (也许 Java 被编译为 WebAssembly 呢? 我也不确定,不过细节不重要;-)

你逈后端核心库深入的 UserId 类屡次结构的维护者提交了 pull request; 然后, 他们拒绝了;

事实上, 他们有很好的理由, 你必须勉强承认; 他们说:"这是一种荒谬的关注点分离"; 此外, 公司也无法从你的前端获得此核心库处理类型;

所以, 你叹了口气, 写了个 Rust 匹配表达式的等价物, 但是用的是 Java (请原谅我荒谬的假设有个 HTML 库):

Html userIdToHtml(UserId userId) {
    if (userId instanceof Username) {
        Username username = (Username)userId;
        String usernameString = username.getUsername();
        Url url = ProfileHandler.getProfileForUsername(usernameString);
        return Link.createTextLink(url, username.getUsername());
    } else if (userId instanceof AnonymousUser) {
        AnonymousUser anonymousUser = (AnonymousUser)userId;
        return Span.createColoredText(anonymousUser.getIp().formatString(), "red");
    } else {
        throw new RuntimeException("IDK, man");
    }
}

你的老板们在代码审查时拒绝了这段代码, 你你使用了 instanceof 反模式, 但是, 后来在你让他们和不接受你的其它补丁的核心库维护者争论之后, 丫们勉强接受了这段代码;

但是, 看看那坨 instanceof 代码有多难看! 难怪 Java 程序员认为这是一种反模式! 但是, 在这种情况中, 已经是最合理的事儿了, 实际上, 是除了实施观察者东西方或是访问者模式又或是其它相当于基础设施的东西之外, 唯一可能的实现, 只是用来创造具有控制反转的实例而已;

当操作集有界(或是接近有限)并且该类的子类数量可能以意想不到的方式增长时, 通过向每个子类追加一个方法来实现操作是有意义的; 可是,通常情况中, 操作的数量又会以意想不到的方式增长, 而子类的数量总是有限的(又或是接近有限);

对于后一种情况, 这种情况比 OOP 拥护者想象的更加常见, Rust 枚举 --- 以及一般的 求和类型 --- 是完美的; 一但你习惯了她们, 你就会发现自己一直在使用;

我要郑重声明, 在所有面向对象的编程语言中,都没有这么糟糕; 在某些情况中, 你可以按任何顺序编写任意类方法组合, 因此,如果你愿意, 可以将所有三个实现写在一个地方; Smalltalk 传统上允许你在一个特殊的浏览器中游览代码库, 你可以在其中看到一个类实现的方法列表, 或者一个接受给定"消息"的类列表, 正如 Smalltalk 所说的那样, 这样你就可以随心所欲的操弄对象了;

(译按: 当然, 你必须在 Salltalk 对应解释器的 IDE 环境中, 一但出了这个对象镜像, 将失去一切观察能力, 这导致 Smalltalk 没办法使用其它传统 IDE)

备选方案 #1: 闭包

Alternative #1: Closures

有时, 一个 OOP 接口或是多态决策只涉及一个实际操作; 在这种情况中,只能使用闭包;

我不想在这方面花太多时间, 因为, 大多数 OOP 程序员已经意识到这点, 并且, 自从他们的 OOP 语言已经赶上了函数式语言, 并获得了 lambda 语法 --- Java 中的 Java 8 , C++ 中的, C++11; 因此, 像 Java 的 Comparator 这种愚蠢的单一方法接口 --- 幸运的是 --- 基本上已经都感染过去式了;

此外, Rust 中的闭包在技术上涉及 traits, 因此,使用和接下来的两个替代方案相同的机制来竀, 所以,也有人可能会争辩说这在 Rust 中并不是真正的独立选项; 然而,在我看来 lambda/闭包和 FnMut/FnOnce/Fn 等 trait 们在美学上和情境上都非常特别, 值得花点时间掌握;

因此, 我将花些时时间来说明这点: 如果你发现自己只使用一种方法编写 trait (Java 接口或是 C++ 类), 请考虑你是否应该改用某种闭包或是 lambda 类型; 毕竟只有你自己才能防止过度设计;

备选方案#2: 具有Traits的多态

就像 Rust 有个比 OOP 类概念更加灵活/强大的封装版本, 正如在上篇文章中讨论的那样, Rust 有一个比 OOP 假设更加强大的多态版本: trait;

trait 就像来自 Java 的接口 (又或是 C++ 中的全抽象超类), 但是,并没有我在文章开头指出的大部分限制; trait 即没有语义约束, 也没有性能约束; trait 在语义和原理方面深受 C++ 模板的启发; C++ 程序员可以将其视为带有concepts/概念的模板 (除非设计的,从一开始就融入编程语言, 而且不必要处理所有不使用它的代码;)

让我们从语义开始: 你可以使用 trait 完成那些无法使用纯 OOP 完成的, 即便你将世界上所有的间接调用都丢给她? 好吧, 在纯粹的 OOP 术语中, 是无法编写像 Rust Eq 和 Ord 这样的接口, 这里给出了非常简单的定义 (Eq 和 Ord 的真正定义在于拓展了其它允许不同类型之间的部分等价和排序的类, 但是, 像这种简化定义, 非部分 Eq 和 Ord 的 Rust 标准库版本确定涵盖了相同类型值之间的等价和排序):

trait Eq {
    fn eq(self, other: &Self) -> bool;
}

pub enum Ordering {
    Less,
    Equal,
    Greater,
}

trait Ord: Eq {
    fn cmp(&self, other: &Self) -> Ordering;
}

看看发生了什么? 就像在 OOP 风格的接口中一样, 这些无法采用 Self 类型的“接受者”类型, 一个 self 秋粮 -- 也就是说,任何实现 trait 的具体类型 (技术上这里是对 Self 或 &Self 的引用); 但是, 和 OOP 风格的接口不同, 这里还能采用另外一个 &Self 类型的参数; 为了实现 Eq 和 Ord, 类型 T 提供了一个函数, 该函数接受对 T 的两个引用; 字面上的意思是: 对 T 的两个引用, 而不是对 T 的一个引用和对 T 或是任何子类的一个引用 (这样的事情在 Rust 中并不存在), 不是对 T 的一个引用和对实现 Eq 的任何其它值的一个引用, 而是对同一具体类型的两个真正的非异构引用, 然后,函数就可以比较她们是否相等(或是进行排序);

这点很就将要,因为, 我们想用这种能力来实现像排序这类方法:

impl Vec<T> {
    pub fn sort(&mut self) where T: Ord {
        // ...
    }
}

OOP-样 多态非常适合异构容器, 其中每个元素都有自己的运行时类型和自己的接口实现; 但是, sort 并不是那样工作的; 你不能对类似 [3, "Hello", true]; 这种集合进行排序; 所有类型都没有合理的顺序;

相反 sort 在同类容器上运行; 所有元素的类型必须匹配, 以便可以相互比较; 他们并不需要每种类型都要有不同的操作实现;

尽管如此, 排序仍然是多态的; 排序算法对于整数和字符串是相同的, 但是, 比较整数和比较字符串是完全不同的操作; 排序算法需要一种方法来调用对其项目的操作 --- 比较 --- 对于不同的类型必须不同, 同时仍然得具有相同的整体代码结构;

可以通过注入一个比较函数来完成, 但是, 很多类型都有一个内在/默认的排序, 而且, 就应该默认使用这个排序; 因此, 多态 --- 并不是 OOP 友好的变体;

请参考以下 Java 定义排序的声明:

static <T extends Comparable<? super T>> 
void sort(List<T> list)

并没有简单的 trait 能要求 T 和其它 T 具有可比性, 以便对 T 进行排序; 相反, 就编程语言而言, T 和其自身而不是任何其它随机类型可比的查清只是作为此方法的一个偶然事件而阐明; 打有什么可以阻止某人以不一致的方式现实 Comparable 接口, 例如让 Integer 实现 Comparable<String>;

(译按: 作死小能手, 说的就是人类自身, 无论出于什么动机,代码只是文本, 对文本当然可以进行任何修改;)

此外, 当实际查找 Comparable 实现时, Rust 将会根据任何比较的第一个参数而不是类型来测定使用什么实现; 通常,她们都是相同类型, 得是不是, 理论上这个列表可以是异构的, 嘦所有对象都 “extend” T, 并且可以实现不同的 Comparable; 计算机必须作额外工作来满足这种可能性, 即便, 这肯定是一个错误;

(译按: 特别是动态语言中, 将用户视为心理不够成熟的小朋友, 尽可能友善/专业/高级/良好/...的猜测用户行为, 并积极将所有异构数据进行合理转换, 以便完成排序...)

由于现在我们已经在脱离语义领域,进入性能领域, 让我们来全面讨论一下性能实现;

正如我们提及的, Java 排序方法要求集合中的每个项目都是完整的对象类型, 这意味着不是将值直接存储在数组中,而是在堆中, 而引用存储在数组中; 这奵基于 trait 的方法是不必要的---值可以直接存在于数组中;

这意味着不同的数组将具有不同的元素大小, 因此, 这也必须由 trait 来处理; 也就是: 值的大小也通过 Size 的 trait 参数化; 大小必须在数组的所有项目间保持一致, 但是,这是可执行的,因为,我们可以表达所有元素实际上是完全相同类型 ---不像 Java 的 Linst 只表达它们是 T 类型或是 T 的某些子类型;

Rust 的排序方法可以通过在运行时将大小信息(来者 Sized 的 trait) 和排序函数(来自 Ord trait)作为整数值和函数指针传递来实现; 这就是类型类在 Haskell 中的工作方式, 这是 Rust trait 本身灵感来源; 这俨然比 Java 更有效, 因为,只有一个排序函数,而不是对比较的每个左进行不同的间接查找, 从而允许间接分支预测在处理器中能工作;

但是, Rust 比这更进一步, 通过单态化来实现 trait; 这类似 C++ 中模板实例化, 但是, 在语义上受到更好的约束; 前提是虽然 sort 在语义上只是一种方法, 但是,在输出的编译代码中, 将为调用她的每个类型 T 输出不同版本的 sort;

C++ 模板会创建臭名昭著的错误消息, 并且难以推理, 因为, 丫们本质上是宏, 而且是笨拙的宏; 即便是 Rust 也不能用自己的宏系统创建很好的错误消息; 而且,编写宏需要专业知识, 这意味着程序员将放弃类型系统的很多处 --- 在我看来, 模板通常被称为编译时 duck 类型的一种形式; 由于这些原因, C++ 中的模板编程通常被认为比 OOP 样式的多态更高级 (也就是说, 读起来更难/更不方便, 而不是更强大);

然而, 在 Rust 中, trait 提供了一种有组织且更连贯的方式来访问类似技术, 获得模板的性能优势, 同时仍然提供可靠类型系统的结构;

备选方案 #3: 动态 trait 对象

Alternative #3: Dynamic Trait Objects

然而, 有时你确实需要完备的运行时多态; 那么你的情况和枚举的情况相反: 有一组可以对值执行的闭包操作, 但是, 这些操作实际执行的操作将以无法提前限制的方式动态变化;

这种情况中, Rust 已经为你提供了 dyn 关键字; 不过, 不要过度使用; 在几乎所有我认为可能合适的情况中, 静态多态和其它设计元素的结合效果总是最好的;

dyn 的合法用例往往出现在涉及控制反转的情况中, 其中框架库采用主循环, 客户端代码说明如何处理各种事件;

  • 在网络编程中, 框架库说明如何处理所有套接字, 并将她们注册到操作系统, 但是, 应用程序需要说明如何实际处理数据;
  • 在 GUI 编程中, 框架代码可以说明点击了什么小部件,但是, 如果该小部件是按钮/文本框/...又或是你为该特定应用发明的自定义小部件,则会发生截然不同的事情;

现在, 你并不需要严格的运行时多态; 你可以改用闭包(甚至于原始函数指针), 如果需要多个操作,就创建闭包结构(或是函数指针) ---这基本上相当于手工完成 dyn 的困难工作; 例如, 我完全希望 tokio 在内部使用 Rust 运行时多态 trait 来处理任务调度中的这种控制反转; 相反, 出于我想象的性能原因, tokio 手动实现 dyn, 甚至于调用其函数指针结构 Vtable;

但是, dyn 的确能为你完成所有这些工作, 为你的 trait; 唯一的要求是你的 trait 必须是对象安全的, 要求列表可能看起来很熟悉, 尤其是当涉及到关联函数(例如方法)的要求是 “dispatchable”:

But dyn does all of this work for you, for your trait. The only requirement is that your trait be object-safe, and the list of requirements may seem familiar, especially when it comes to the requirements for an associated function (e.g. a method) to be “dispatchable”:


  • 没有任何类型参数(尽管允许使用生命周期秋粮),
  • 是一个不使用 Self 的方法, 除了接收者的类型
  • 具有以下类型之一的接收器:
    • &Self (i.e. &self)
    • &mut Self (i.e &mut self)
    • Box
    • Rc
    • Arc
    • Pin ~ 其中 P 是去述类型之一
  • 没有 where Self: Sized bound ( Self 的接收者类型, 即, self 暗示了这点).

也就是说, 可以仅在一个参数上是多态的, 并且, 该参数必须是引用 --- 或多少是支持 OOP 中运行时多态的方法的确切要求;

这当然是因为 dyn 使用和 OOP 几乎完全相同的机制来实现运行时多态: “vtable” ; Box<dyn Foo> 实际上包含两个指针而不是一个, 一个指向所讨论的对象, 一个指向“vtable”, 即, 为该类型自动生成的函数指针结构; 单参数要求, 是因为那个参数的 “vtable” 用于查找要调用的方法的具体实现, 而间接要求, 是因为具体类型可能有不同的大小, 只有在运行时才知道尺寸;

需要明确的是, 这些都是对运行时多态的一种特定实现策略的限制; 存在将 “vtable” 和类型的各个值完全分离的替代策略, 比如在 Haskell 中;

和 OOP 风格的接口相比, Rust 版本的运行时多态和 trait 仍然有一些优势;

在性能方面, 是和类型一起完成的, 而不是类型固有的; 普通值不存储“vtable” ,将其成本分散到了整个程序, 而是仅仅在创建 dyn 针对时才引用 “vtable” ; 如果你从未创建指向给定类型值的动态指针, 则,甚至不必创建该类型的 “vtable”; 当然, 你不会在所有 “vtable” 指针的每次分配中, 都有8个字节的额外垃圾! 这也意味着减少了一层间接调用;

从语义上讲, 这只是众多选项中的一个, 这也是一件好事儿,而且,并不是整个编程语言都试图将你强力推向的首选选项; 甚至于通常情况中, 静态多态/枚举, 甚至只是好的老式闭包, 都能更加准备的代表手上的问题, 那就应该替用回来;

最后, Rust 中的运行时和静态多态都使用 trait 这一事实, 也使得从一个系统切换到另外一个系统变得更加容易; 如果你发现自己将 dyn 用于某个 trait, 则不必要在使用该 trait 的所有地方都使用; 你可以改用静态多态机制 (比如类型参数和 impl trait), 自由的混合以及匹配相同的 trait;

和 C++ 不同, 你不必为概念和父类学习两组完全不同的语法, 以及大量完全不同的语义; 实际上, 在 Rust 中, 动态多态只是静态多态的一个特例, 唯一的区别是实际上不同的东西;

logging

  • ...
  • 230309 ZQ v0 done
  • 230215 ZQ init.
         _~^&∽~_
     \/ /  O ^  \ \/
       '_   v   _'
       / '--#--' )

...act by ferris-actor v0.2.4 (built on 23.0303.201916)

评论区

写评论

还没有评论

1 共 0 条评论, 1 页