MoonBit Pearls Vol.02:MoonBit中的面向对象编程
引言
在软件开发的世界里,面向对象编程(OOP)无疑是一座绕不开的话题。Java、C++ 等语言凭借其强大的 OOP 机制构建了无数复杂的系统。然而,Moonbit,作为一门围绕函数式编程构建的现代语言,它如何实现 OOP?
Moonbit 是一门以函数式编程为核心的语言,它的面向对象编程思路与传统编程语言有很大不同。它抛弃了传统的继承机制,拥抱"组合优于继承"的设计哲学。乍一看,这可能让习惯了传统OOP的程序员有些不适应,但细细品味,你会发现这种方法有着意想不到的优雅和实用性。
本文将通过一个生动的RPG游戏开发例子,带你深入体验Moonbit中的面向对象编程。我们会逐一剖析封装、继承和多态这三大特性,并与C++的实现方式进行对比,最后提供一些实际开发中的最佳实践建议。
封装(Encapsulation)
想象一下,我们要开发一款经典的单机RPG游戏。在这个奇幻世界 里,英雄四处游历,与怪物战斗,向NPC商人购买装备,最终拯救被困的公主。要构建这样一个世界,我们首先需要对其中的所有元素进行建模。
不管是勇敢的英雄、凶恶的怪物,还是朴实的桌椅板凳,它们在游戏世界中都有一些共同的特征。我们可以将这些对象都抽象为Sprite
(精灵),每个Sprite都应该具备几个基本属性:
ID
:对象的唯一标识符,就像身份证号码一样。x
和y
:在游戏地图上的坐标位置。
C++的经典封装方式
在C++的世界里,我们习惯于用class
来构建数据的封装:
// 一个基础的 Sprite 类
class Sprite {
private:
int id;
double x;
double y;
public:
// 构造函数,用来创建对象
Sprite(int id, double x, double y) : id(id), x(x), y(y) {}
// 提供一些公共的 "getter" 方法来访问数据
int getID() const { return id; }
double getX() const { return x; }
double getY() const { return y; }
// 可能还需要 "setter" 方法来修改数据
void setX(double newX) { x = newX; }
void setY(double newY) { y = newY; }
};
你可能会问:"为什么要搞这么多get
方法,直接把属性设为public
不就好了?"这就涉及到封装的核心思想了。
为什么需要封装?
想象一下,如果你的同事直接通过
sprite.id = enemy_id
来修改ID,英雄瞬间就能"变身"成敌人的同伙,直接大摇大摆地走到终点——但这显然不是我们想要的游戏机制!封装就像给数据加了一道防护网,private
字段配合getter
方法,确保外部只能读取而无法随意修改关键数据。这样的设计让代码更加健壮,避免了意想不到的副作用。
Moonbit的优雅封装
到了Moonbit这里,封装的思路发生了微妙而重要的变化。让我们先看一个简单的版本:
// 在 Moonbit 中定义 Sprite
pub struct Sprite {
id: Int // 默认不可变,外部可读但不可写
mut x: Double // mut 关键字表示可变
mut y: Double
}
// 我们可以为 struct 定义方法
pub fn Sprite::get_x(self: Self) -> Double {
self.x
}
pub fn Sprite::get_y(self: Self) -> Double {
self.y
}
pub fn Sprite::set_x(self: Self, new_x: Double) -> Unit {
self.x = new_x
}
pub fn Sprite::set_y(self: Self, new_y: Double) -> Unit {
self.y = new_y
}
注意到这里有两个关键的不同点:
1. 可变性的显式声明
在Moonbit中,字段默认是不可变的(immutable)。如果你想让某个字段可以被修改,必须明确使用mut
关键字。在我们的Sprite
中,id
保持不可变——这完美符合我们的设计意图,毕竟我们不希望对象的身份被随意篡改。而x
和y
被标记为mut
,因为精灵需要在世界中自由移动。
2. 更简洁的访问控制
由于id
本身就是不可变的,我们甚至不需要为它编写get_id
方法!外部代码可以直接通过sprite.id
来读取它,但任何尝试修改的行为都会被编译器坚决拒绝。这比C++的"private + getter"模式更加简洁明了,同时保持了同样的安全性。
💡 实践建议
在设计数据结构时,优先考虑哪些字段真正需要可变。Moonbit的默认不可变设计能帮你避免很多意外的状态修改bug。
继承(Inheritance)
面向对象编程的第二大支柱是继承。在我们的RPG世界中,会有多种不同类型的Sprite。为了 简化示例,我们定义三种:
Hero
(英雄):玩家操控的角色Enemy
(敌人):需要被击败的对手Merchant
(商人):售卖道具的NPC
C++的继承层次
在C++中,我们很自然地使用类继承来构建这种层级关系:
class Hero : public Sprite {
private:
double hp;
double damage;
int money;
public:
Hero(int id, double x, double y, double hp, double damage, int money)
: Sprite(id, x, y), hp(hp), damage(damage), money(money) {}
void attack(Enemy& e) { /* ... */ }
};
class Enemy : public Sprite {
private:
double hp;
double damage;
public:
Enemy(int id, double x, double y, double hp, double damage)
: Sprite(id, x, y), hp(hp), damage(damage) {}
void attack(Hero& h) { /* ... */ }
};
class Merchant : public Sprite {
public:
Merchant(int id, double x, double y) : Sprite(id, x, y) {}
// 商人专有的方法...
};
C++的面向对象建立在 "is-a" 关系基础上:Hero
是一个Sprite
,Enemy
是一个Sprite
。这种思维方式直观且容易理解。
Moonbit的组合式思维
现在轮到Moonbit了。这里需要进行一次重要的思维转换:Moonbit的struct
不支持直接继承。取而代之的是使用trait
(特质)和组合(Composition)。
这种设计迫使我们重新思考问题:我们不再将Sprite
视为可被继承的"父类",而是将其拆分为两个独立的概念:
SpriteData
:一个纯粹的数据结构,存储所有Sprite共享的数据Sprite
:一个trait,定义所有Sprite应该具备的行为能力
让我们看看实际的代码:
// 1. 定义共享的数据结构
pub struct SpriteData {
id: Int
mut x: Double
mut y: Double
}
// 2. 定义描述通用行为的 Trait
pub trait Sprite {
getSpriteData(Self) -> SpriteData // 必须实现的核心方法
getID(Self) -> Int = _ // = _ 表示有默认实现
getX(Self) -> Double = _
getY(Self) -> Double = _
setX(Self, Double) -> Unit = _
setY(Self, Double) -> Unit = _
}
// Sprite的默认实现
// 只要实现了 getSpriteData,就自动拥有了其他方法
impl Sprite with getID(self) {
self.getSpriteData().id
}
impl Sprite with getX(self) {
self.getSpriteData().x
}
impl Sprite with getY(self) {
self.getSpriteData().y
}
impl Sprite with setX(self, new_x) {
self.getSpriteData().x = new_x
}
impl Sprite with setY(self, new_y) {
self.getSpriteData().y = new_y
}
理解Trait的威力
Sprite
trait定义了一个"契约":任何声称自己是Sprite
的类型,都必须能够提供它的SpriteData
。一旦满足了这个条件,getID
、getX
、getY
等方法就会自动可用。这里的= _
语法表示该方法有默认实现,这是Moonbit的最新语法特性。
有了这个基础架构,我们就可以实现具体的 游戏角色了:
// 定义Hero
pub struct Hero {
SpriteData
sprite_data: struct SpriteData {
id: Int
mut x: Double
mut y: Double
}
SpriteData // 组合SpriteData
Double
hp: Double
Double
Int
damage: Int
Int
Int
money: Int
Int
}
// 实现Sprite trait,只需要提供getSpriteData方法
pub impl trait Sprite {
getSpriteData(Self) -> SpriteData
asSpriteEnum(Self) -> SpriteEnum
tryAsWarrior(Self) -> &Warrior?
getID(Self) -> Int
getX(Self) -> Double
getY(Self) -> Double
setX(Self, Double) -> Unit
setY(Self, Double) -> Unit
}
Sprite for struct Hero {
sprite_data: SpriteData
hp: Double
damage: Int
money: Int
}
Hero with (self : Hero) -> SpriteData
getSpriteData(Hero
self) {
Hero
self.SpriteData
sprite_data
}
pub fn struct Hero {
sprite_data: SpriteData
hp: Double
damage: Int
money: Int
}
Hero::(self : Hero, e : Enemy) -> Unit
attack(Hero
self: struct Hero {
sprite_data: SpriteData
hp: Double
damage: Int
money: Int
}
Self, Enemy
e: struct Enemy {
sprite_data: SpriteData
hp: Double
damage: Int
}
Enemy) -> Unit
Unit {
// 攻击逻辑...
}
// 定义Enemy
pub struct Enemy {
SpriteData
sprite_data: struct SpriteData {
id: Int
mut x: Double
mut y: Double
}
SpriteData
Double
hp: Double
Double
Int
damage: Int
Int
}
pub impl trait Sprite {
getSpriteData(Self) -> SpriteData
asSpriteEnum(Self) -> SpriteEnum
tryAsWarrior(Self) -> &Warrior?
getID(Self) -> Int
getX(Self) -> Double
getY(Self) -> Double
setX(Self, Double) -> Unit
setY(Self, Double) -> Unit
}
Sprite for struct Enemy {
sprite_data: SpriteData
hp: Double
damage: Int
}
Enemy with (self : Enemy) -> SpriteData
getSpriteData(Enemy
self) {
Enemy
self.SpriteData
sprite_data
}
pub fn struct Enemy {
sprite_data: SpriteData
hp: Double
damage: Int
}
Enemy::(self : Enemy, h : Hero) -> Unit
attack(Enemy
self: struct Enemy {
sprite_data: SpriteData
hp: Double
damage: Int
}
Self, Hero
h: struct Hero {
sprite_data: SpriteData
hp: Double
damage: Int
money: Int
}
Hero) -> Unit
Unit {
// 攻击逻辑...
}
// 定义Merchant
pub struct Merchant {
SpriteData
sprite_data: struct SpriteData {
id: Int
mut x: Double
mut y: Double
}
SpriteData
}
pub impl trait Sprite {
getSpriteData(Self) -> SpriteData
asSpriteEnum(Self) -> SpriteEnum
tryAsWarrior(Self) -> &Warrior?
getID(Self) -> Int
getX(Self) -> Double
getY(Self) -> Double
setX(Self, Double) -> Unit
setY(Self, Double) -> Unit
}
Sprite for struct Merchant {
sprite_data: SpriteData
}
Merchant with (self : Merchant) -> SpriteData
getSpriteData(Merchant
self) {
Merchant
self.SpriteData
sprite_data
}
注意这里的思维方式转变:Moonbit采用的是 "has-a" 关系,而不是传统OOP的 "is-a" 关系。Hero
拥有SpriteData
,并且实现了Sprite
的能力。
看起来Moonbit更复杂?
初看之下,Moonbit的代码似乎比C++要写更多"模板代码"。但这只是表面现象!我们这里刻意回避了C++的诸多复杂性:构造函数、析构函数、const正确性、模板实例化等等。更重要的是,Moonbit这种设计在大型项目中会展现出巨大优势——我们稍后会详细讨论这一点。
多态(Polymorphism)
多态是面向对象编程的第三大支柱,指的是同一个接口作用于不同对象时产生不同行为的能力。让我们通过一个具体例子来理解:假设我们需要实现一个who_are_you
函数,它能够识别传入对象的类型并给出相应回答。
C++的多态机制
C++的多态机制实际上是一个比较复杂的问题,笼统地说,它包括静态多态(模板)和动态多态(虚函数、RTTI等)。对C++多态机制的讨论超出了我们这篇文章的内容范围,读者如果有兴趣可以自行查阅相关书籍。这里我们重点讨论两种经典的运行时多态方法。
方法一:虚函数机制
最传统的做法是为基类定义虚函数,让子类重写:
class Sprite {
public:
virtual ~Sprite() = default; // 虚析构函数
// 定义一个"纯虚函数",强制子类必须实现它
virtual std::string say_name() const = 0;
};
// 在子类中"重写"(override)这个函数
class Hero : public Sprite {
public:
std::string say_name() const override {
return "I am a hero!";
}
// ...
};
class Enemy : public Sprite {
public:
std::string say_name() const override {
return "I am an enemy!";
}
// ...
};
class Merchant : public Sprite {
public:
std::string say_name() const override {
return "I am a merchant.";
}
// ...
};
// 现在 who_are_you 函数变得极其简单!
void who_are_you(const Sprite& s) {
std::cout << s.say_name() << std::endl;
}
方法二:RTTI + dynamic_cast
如果我们不想为每个类单独定义虚函数,还可以使用C++的运行时类型信息(RTTI):
class Sprite {
public:
// 拥有虚函数的类才能使用 RTTI
virtual ~Sprite() = default;
};
// who_are_you 函数的实现
void who_are_you(const Sprite& s) {
if (dynamic_cast<const Hero*>(&s)) {
std::cout << "I am a hero!" << std::endl;
} else if (dynamic_cast<const Enemy*>(&s)) {
std::cout << "I am an enemy!" << std::endl;
} else if (dynamic_cast<const Merchant*>(&s)) {
std::cout << "I am a merchant." << std::endl;
} else {
std::cout << "I don't know who I am" << std::endl;
}
}
RTTI的工作原理
开启RTTI后,C++编译器会为每个有虚函数的对象维护一个隐式的
type_info
结构。当使用dynamic_cast
时,编译器检查这个类型信息:匹配则返回有效指针,不匹配则返回nullptr
。这种机制虽然功能强大,但也带来了运行时开销。
不过,第二种方法在大型项目中存在一些问题:
- 类型不安全。如果你新增了一个子类但忘记修改
who_are_you
函数,这个bug只能在运行时才能被发现!在现代软件开发中,我们更希望此类错 误能在编译时就被捕获。 - 性能不够好。开启RTTI后,每一次判断类型都会调用一个比较麻烦的类型信息读取方法,这不太利于优化,因此很容易出现性能上的问题。
- 数据不透明。开启RTTI后,C++会为每一个类隐式地添加一块类型信息,但是代码的编写者是看不到的,这对于一些期望对代码拥有更强掌控力的库编写者而言非常头疼。事实上,不少大型项目会考虑禁用RTTI,最典型的就是LLVM,这个C++的编译器项目反而自己并不愿意使用RTTI.
Moonbit的ADT机制
Moonbit通过引入代数数据类型(Algebraic Data Type,ADT)来优雅地解决多态问题。我们需要添加一个新的结构——SpriteEnum
:
pub trait Sprite {
getSpriteData(Self) -> SpriteData
asSpriteEnum(Self) -> SpriteEnum // 新增:类型转换方法
}
// Moonbit允许enum的标签名和类名重名
pub enum SpriteEnum {
Hero(Hero)
Enemy(Enemy)
Merchant(Merchant)
}
// 我们仍然需要实现Sprite中的getSpriteData
pub impl Sprite for Hero with getSpriteData(self) {
self.sprite_data
}
pub impl Sprite for Enemy with getSpriteData(self) {
self.sprite_data
}
pub impl Sprite for Merchant with getSpriteData(self) {
self.sprite_data
}
// 为三个子类实现 asSpriteEnum 方法
// 这里实际上是将具体类型"装箱"到enum中
pub impl Sprite for Hero with asSpriteEnum(self) {
Hero(self) // 注意:这里的Hero是enum标签,不是类型
}
pub impl Sprite for Enemy with asSpriteEnum(self) {
Enemy(self)
}
pub impl Sprite for Merchant with asSpriteEnum(self) {
Merchant(self)
}
现在我们可以实现类型安全的who_are_you
函数了:
test "who are you" {
fn who_are_you(s: &Sprite) -> String {
// 使用模式匹配进行类型分发
match s.asSpriteEnum() {
Hero(_) => "hero"
Enemy(_) => "enemy"
Merchant(_) => "merchant"
}
}
let hero = Hero::new();
let enemy = Enemy::new();
let merchant = Merchant::new();
inspect(who_are_you(hero), content="hero")
inspect(who_are_you(enemy), content="enemy")
inspect(who_are_you(merchant), content="merchant")
}
这种方法的美妙之处在于:它是编译时类型安全的!如果你添加了一个新的Sprite
子类但忘记修改who_are_you
函数,编译器会立即报错,而不是等到运行时才发现问题。
静态分发 vs 动态分发
你可能注意到函数签名中的
&Sprite
。这在Moonbit中被称为Trait Object,支持动态分发,类似于C++的虚函数机制。如果你写成fn[S: Sprite] who_are_you(s: S)
,那就是静态分发(泛型),编译器会为每种具体类型生成专门的代码。两者的关键区别在于处理异构集合的能力。假设英雄有AOE技能需要攻击一个包含不同类型敌人的数组,你必须使用
Array[&Sprite]
而不是Array[V]
,因为后者无法同时容纳不同的具体类型。
当然,Moonbit也支持类似C++虚函数的直接方法调用:
pub trait SayName {
say_name(Self) -> String
}
pub impl SayName for Hero with say_name(_) {
"hero"
}
pub impl SayName for Enemy with say_name(_) {
"enemy"
}
pub impl SayName for Merchant with say_name(_) {
"merchant"
}
test "say_name" {
fn who_are_you(s: &SayName) -> String {
s.say_name() // 直接调用trait方法,类似虚函数
}
let hero = Hero::new();
let enemy = Enemy::new();
let merchant = Merchant::new();
inspect(who_are_you(hero), content="hero")
inspect(who_are_you(enemy), content="enemy")
inspect(who_are_you(merchant), content="merchant")
}
显式化的RTTI
实际上,Moonbit的ADT方法就是将C++隐式的RTTI过程显式化了。开发者明确知道有哪些类型,编译器也能在编译时进行完整性检查。
多层继承:构建复杂的能力体系
随着游戏系统的发展,我们发现Hero
和Enemy
都有hp
(生命值)、damage
(攻击力)和attack
方法。能否将这些共同特征抽象出来,形成一个Warrior
(战士)层级呢?
C++的多层继承
在C++中,我们可以很自然地在继承链中插入新的中间层:
class Warrior : public Sprite {
protected: // 使用 protected,子类可以访问
double hp;
double damage;
public:
Warrior(int id, double x, double y, double hp, double damage)
: Sprite(id, x, y), hp(hp), damage(damage) {}
virtual void attack(Sprite& target) = 0; // 战士都能攻击
double getHP() const { return hp; }
double getDamage() const { return damage; }
};
class Hero final : public Warrior {
private:
int money;
public:
Hero(int id, double x, double y, double hp, double damage, int money)
: Warrior(id, x, y, hp, damage), money(money) {}
};
class Enemy final : public Warrior {
public:
Enemy(int id, double x, double y, double hp, double damage)
: Warrior(id, x, y, hp, damage) {}
};
class Merchant final : public Sprite {
public:
Merchant(int id, double x, double y) : Sprite(id, x, y) {}
}; // 商人仍然直接继承 Sprite
这形成了一个清晰的继承链:Sprite → Warrior → Hero/Enemy
,Sprite → Merchant
。
Moonbit的组合式多层能力
在Moonbit中,我们继续坚持组合的思路,构建一个更灵活的能力体系:
pub struct WarriorData {
hp: Double
damage: Double
}
// Warrior trait 继承自 Sprite,形成能力层次
pub trait Warrior : Sprite {
getWarriorData(Self) -> WarriorData
asWarriorEnum(Self) -> WarriorEnum
attack(Self, target: &Warrior) -> Unit = _ // 默认实现
}
pub enum WarriorEnum {
Hero(Hero)
Enemy(Enemy)
}
// 重新定义Hero,现在它组合了两种数据
pub struct Hero {
sprite_data: SpriteData // 基础精灵数据
warrior_data: WarriorData // 战士数据
money: Int // 英雄特有数据
}
// Hero 需要实现多个 trait
pub impl Sprite for Hero with getSpriteData(self) {
self.sprite_data
}
pub impl Warrior for Hero with getWarriorData(self) {
self.warrior_data
}
pub impl Warrior for Hero with asWarriorEnum(self) {
Hero(self)
}
// 重新定义Enemy
pub struct Enemy {
sprite_data: SpriteData
warrior_data: WarriorData
}
pub impl Sprite for Enemy with getSpriteData(self) {
self.sprite_data
}
pub impl Warrior for Enemy with getWarriorData(self) {
self.warrior_data
}
pub impl Warrior for Enemy with asWarriorEnum(self) {
Enemy(self)
}
有时我们也可能会遇到需要将父基类转换成子基类的场景。例如,我们的商人可能对不同的Sprite做出不同的反应:当他遇到一个Warrior时,他会说"Want to buy something?",当他遇到另一个商人时,则什么也不做。这个时候,我们就需要把Sprite父基类转换成Warrior子基类。推荐的方式是为Sprite
trait添加一个tryAsWarrior
的函数:
pub trait Sprite {
// other methods
tryAsWarrior(Self) -> &Warrior? = _ // 尝试转换为Warrior
}
impl Sprite with tryAsWarrior(self) {
match self.asSpriteEnum() {
// 第一项需要添加 as &Warrior, 来告知编译器整个表达式返回一个&Warrior
// 如果不加这个as语句,编译器就会根据第一个表达式的类型
// 判断整个表达式的类型为Hero,从而引发编译错误。
Hero(h) => Some(h as &Warrior)
Enemy(e) => Some(e)
_ => None
}
}
pub fn Merchant::ask(self: Merchant, s: &Sprite) -> String {
match s.tryAsWarrior() {
Some(_) => "Want to buy something?" // 对战士说话
None => "" // 对其他类型保持沉默
}
}
这种设计的精妙之处在于它的极致灵活性:
Hero
和Enemy
通过组合SpriteData
和WarriorData
,同时实现Sprite
和Warrior
两个trait,获得了所需的全部能力Merchant
只需要组合SpriteData
并实现Sprite
trait即可- 如果将来要引入
Mage
(法师)能力,只需定义MageData
和Mage
trait - 一个角色甚至可以同时是
Warrior
和Mage
,成为"魔剑士",而不需要处理C++中的菱形继承 问题
菱形继承问题
假设我们要创建一个既是商人又是敌人的
Profiteer
(奸商)类。在C++中,如果Profiteer
同时继承Enemy
和Merchant
,就会出现菱形继承:Profiteer
会拥有两份Sprite
数据!这可能导致修改了一份数据,但调用时却使用了另一份的诡异bug。Moonbit的组合方式从根本上避免了这个问题。
传统面向对象编程的深层问题
看到这里,你可能会想:"Moonbit的方法需要写更多代码,看起来更复杂啊!"确实,从代码行数来看,Moonbit似乎需要更多的"模板代码"。但是,在真实的软件工程实践中,传统的面向对象编程方式实际上存在诸多深层问题:
1. 脆弱的继承链
问题:对父类的任何修改都会影响所有子类,可能产生难以预估的连锁反应。
想象一下你的RPG游戏已经发布了两年,拥有上百种不同的Sprite
子类。现在你需要给基础的Sprite
类做一个重构。然而,你可能很快就会发现这并不现实。在传统继承体系中,这个改动会影响到每一个子类,即便是很小的改动可能也影响巨大。某些子类可能因为这个改动出现意外的行为变化,而你需要逐一检查和测试所有相关代码。
Moonbit的解决方案:组合式设计让我们可以通过ADT直接找到Sprite的所有子类,立刻知道重构代码的影响范围。
2. 菱形继承的噩梦
问题:多重继承容易导致菱形继承,产生数据重复和方法调用歧义。
如前所述,Profiteer
类同时继承Enemy
和Merchant
时,会拥有两份Sprite
数据。这不仅浪费内存,更可能导致数据不一致的bug。
Moonbit的解决方案:组合天然避免了这个问题,Profiteer
可以拥有SpriteData
、WarriorData
和MerchantData
,清晰明了。
3. 运行时错误的隐患
问题:传统OOP的许多问题只能在运行时被发现,增加了调试难度和项目风险。
还记得前面dynamic_cast
的例子吗?如果你添加了新的子类但忘记更新相关的类型判断代码,只有在程序运行到那个分支时才会暴露问题。在大型项目中,这可能意味着bug在生产环境中才被发现。
Moonbit的解决方案:ADT配合模式匹配提供编译时类型安全。遗漏任何一个case,编译器都会报错。