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的威力
Spritetrait定义了一个"契约":任何声称自己是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 fn Sprite::getSpriteData(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::fn Hero::attack(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 fn Sprite::getSpriteData(self : Enemy) -> SpriteData
getSpriteData(Enemy
self) {
Enemy
self.SpriteData
sprite_data
}
pub fn struct Enemy {
sprite_data: SpriteData
hp: Double
damage: Int
}
Enemy::fn Enemy::attack(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 fn Sprite::getSpriteData(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.