面向对象编程(OOP)
面向对象编程是一种将数据与操作数据的函数绑定在一起组织代码的范式。它解决的核心问题是:当程序规模增长时,如何让代码更易于理解、修改和复用。
1. 概念——为什么需要面向对象
1.1 面向过程的问题
在面向过程的代码中,数据和函数是分离的:
// 面向过程:数据与操作分离
struct RobotState {
float x, y; // 位置
float vx, vy; // 速度
float heading; // 朝向
};
void moveForward(RobotState* robot, float distance) {
robot->x += distance * cos(robot->heading);
robot->y += distance * sin(robot->heading);
}
void rotate(RobotState* robot, float angle) {
robot->heading += angle;
}
这种方式在项目较小时可行,但随着代码膨胀,问题逐渐显现:
- 任何函数都可以随意修改
RobotState的内部数据,缺乏保护 - 新增一种机器人类型(如带云台的机器人)时,所有相关函数都要修改
- 数据与操作之间的关联关系不在代码中显式表达,阅读成本高
1.2 面向对象的解决思路
面向对象将数据和操作数据的方法封装在一起,形成"类"(Class):
// 面向对象:数据与操作绑定
class Robot {
public:
void moveForward(float distance) {
m_x += distance * cos(m_heading);
m_y += distance * sin(m_heading);
}
void rotate(float angle) {
m_heading += angle;
}
float getX() const { return m_x; }
float getY() const { return m_y; }
private:
float m_x = 0.0f, m_y = 0.0f;
float m_vx = 0.0f, m_vy = 0.0f;
float m_heading = 0.0f;
};
类比:类是"蓝图"或"模板",对象是根据蓝图创建出来的具体"实例"。一份 Chassis 类的定义可以创建出 chassis1、chassis2 等不同的底盘对象,它们结构相同但数据独立。
2. 类与对象
2.1 类的基本结构
class Chassis {
public: // 公有成员——外部可以访问
void setSpeed(float vx, float vy) {
m_vx = vx;
m_vy = vy;
}
float getSpeedX() const { return m_vx; } // const 表示不修改成员变量
private: // 私有成员——只有类内部可以访问
float m_vx = 0.0f;
float m_vy = 0.0f;
float m_omega = 0.0f;
};
public 和 private 是访问控制符,它们决定了哪些代码可以访问类成员。这是封装(Encapsulation)的语法基础。
2.2 访问控制的完整规则
| 修饰符 | 类内部访问 | 子类访问 | 外部访问 |
|---|---|---|---|
public |
✓ | ✓ | ✓ |
protected |
✓ | ✓ | ✗ |
private |
✓ | ✗ | ✗ |
封装的核心理念:将数据设为 private,通过 public 接口提供受控的访问。这样外部代码无法随意篡改对象内部状态,降低了 bug 的产生概率。
2.3 创建和使用对象
int main() {
Chassis chassis; // 在栈上创建对象
chassis.setSpeed(1.0f, 0.5f); // 通过公有接口操作
// chassis.m_vx = -1.0f; // ❌ 编译错误:m_vx 是 private 成员
Chassis* pChassis = new Chassis(); // 在堆上创建对象
pChassis->setSpeed(2.0f, 0.0f); // 指针用 -> 访问成员
delete pChassis; // 手动释放堆上对象
}
2.4 声明与定义分离
在 GSRL 等工程中,类的声明通常放在 .hpp 头文件,成员函数的实现放在 .cpp 源文件:
// chassis.hpp —— 声明
class Chassis {
public:
void setSpeed(float vx, float vy); // 只声明,不实现
float getSpeedX() const;
private:
float m_vx = 0.0f, m_vy = 0.0f;
};
// chassis.cpp —— 定义(实现)
void Chassis::setSpeed(float vx, float vy) {
m_vx = vx;
m_vy = vy;
}
float Chassis::getSpeedX() const {
return m_vx;
}
Chassis:: 是作用域限定符,告诉编译器这个函数属于 Chassis 类。分离声明与定义有助于控制编译依赖、加快增量编译速度。
3. 构造函数与析构函数
3.1 构造函数(Constructor)
构造函数在对象创建时自动调用,负责初始化对象。其名称与类名相同,没有返回值。
class SimplePID {
public:
// 带参数的构造函数
SimplePID(PIDMode mode, float kp, float ki, float kd)
: m_mode(mode) // 初始化列表
, m_kp(kp)
, m_ki(ki)
, m_kd(kd)
{}
private:
PIDMode m_mode;
float m_kp, m_ki, m_kd;
float m_integral = 0.0f; // 类内初始值(C++11)
};
// 使用
SimplePID pid(PIDMode::PID_POSITION, 1.0f, 0.1f, 0.05f);
初始化列表 vs 函数体内赋值:
初始化列表直接在对象构造时初始化成员,而函数体内赋值是先默认构造再赋值:
// 初始化列表(推荐)
SimplePID(PIDMode mode, float kp)
: m_mode(mode), m_kp(kp) // 直接初始化,一步到位
{}
// 函数体内赋值(不推荐)
SimplePID(PIDMode mode, float kp) {
m_mode = mode; // 先默认构造,再赋值 = 两步
m_kp = kp;
}
对于 int、float 等基本类型,差异很小。但对于 std::string、自定义类等复杂类型,初始化列表避免了额外的默认构造 + 赋值开销。
默认构造函数: 如果类没有定义任何构造函数,编译器会自动生成一个无参的默认构造函数。一旦定义了任意构造函数(有参的),编译器就不再生成默认构造函数——此时若仍需要无参构造,必须手动定义。
3.2 析构函数(Destructor)
析构函数在对象生命周期结束时自动调用,负责释放资源。名称是 ~类名(),无参数无返回值。
class DataLogger {
public:
DataLogger(size_t size) {
m_buffer = new float[size]; // 在堆上分配
m_size = size;
}
~DataLogger() {
delete[] m_buffer; // 释放堆上内存
}
private:
float* m_buffer;
size_t m_size;
};
关键规则:
- 栈上的对象在离开作用域时自动调用析构函数
- 堆上的对象在 delete 时调用析构函数
- 如果类通过 new[] 分配了资源,析构函数中必须用 delete[] 释放(不能混用 delete)
3.3 拷贝构造与拷贝赋值
当对象被拷贝时,需要决定"怎么拷贝"。默认的拷贝行为是逐成员拷贝(浅拷贝),这对持有指针的类是危险的:
DataLogger a(100);
DataLogger b = a; // 浅拷贝:a.m_buffer 和 b.m_buffer 指向同一块内存!
// a 和 b 析构时会重复 delete 同一块内存 → 崩溃
正确的做法是定义深拷贝:
class DataLogger {
public:
// 拷贝构造:分配新内存,复制数据
DataLogger(const DataLogger& other)
: m_size(other.m_size)
{
m_buffer = new float[m_size];
for (size_t i = 0; i < m_size; ++i) {
m_buffer[i] = other.m_buffer[i];
}
}
// 拷贝赋值:释放旧内存,分配新内存,复制数据
DataLogger& operator=(const DataLogger& other) {
if (this != &other) { // 防止自赋值
delete[] m_buffer; // 释放旧内存
m_size = other.m_size;
m_buffer = new float[m_size];
for (size_t i = 0; i < m_size; ++i) {
m_buffer[i] = other.m_buffer[i];
}
}
return *this;
}
~DataLogger() { delete[] m_buffer; }
private:
float* m_buffer = nullptr;
size_t m_size = 0;
};
三法则(Rule of Three): 如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任意一个,那么它很可能需要全部三个。因为它们都涉及资源管理。
GSRL 的嵌入式环境中,许多类通过静态分配避免了动态内存,因此不需要自定义拷贝/析构。但如果类内部使用了
new,就必须正确处理三法则。
4. 继承(Inheritance)
继承允许一个类复用另一个类的接口和实现,表达"是一个(is-a)"的关系。
4.1 基本语法
// 基类:过滤器接口
class Filter {
public:
virtual ~Filter() = default;
virtual float update(float input) = 0; // 纯虚函数,详见《多态.md》
};
// 派生类:卡尔曼滤波器 "是一个" 过滤器
class KalmanFilter : public Filter {
public:
float update(float input) override {
// 卡尔曼滤波的具体实现
return m_state;
}
private:
float m_state = 0.0f;
};
// 派生类:移动平均滤波器 "是一个" 过滤器
class MovingAverage : public Filter {
public:
float update(float input) override {
// 移动平均的具体实现
return m_average;
}
private:
float m_average = 0.0f;
};
4.2 继承类型
class Child : public Parent {}; // 公有继承:父类 public → 子类 public(最常见)
class Child : protected Parent {}; // 保护继承:父类 public → 子类 protected
class Child : private Parent {}; // 私有继承:父类 public → 子类 private
| 继承方式 | 父类 public 在子类中变为 |
父类 protected 在子类中变为 |
使用场景 |
|---|---|---|---|
public |
public |
protected |
"是一个"关系(标准多态) |
protected |
protected |
protected |
复用实现但不暴露接口 |
private |
private |
private |
纯实现复用("用...实现") |
GSRL 中绝大多数继承是 public 继承。如果只是为了复用代码而非表达"是一个"关系,优先考虑组合(将对象作为成员变量)而非继承。
4.3 子类构造函数的初始化顺序
class Filter {
public:
Filter(float cutoff) : m_cutoff(cutoff) {}
protected:
float m_cutoff;
};
class KalmanFilter : public Filter {
public:
KalmanFilter(float cutoff, float q)
: Filter(cutoff) // 先调用父类构造函数
, m_q(q) // 再初始化子类成员
{}
private:
float m_q;
};
构造顺序: 父类 → 子类成员(按声明顺序) → 子类构造函数体
析构顺序: 子类析构函数体 → 子类成员 → 父类(与构造顺序相反)
5. 静态成员
静态成员属于类本身,而不是属于某个特定对象。所有对象共享同一份静态成员。
class Chassis {
public:
static int getInstanceCount() { return s_instanceCount; }
Chassis() { s_instanceCount++; }
~Chassis() { s_instanceCount--; }
private:
static int s_instanceCount; // 声明
};
int Chassis::s_instanceCount = 0; // 定义(必须在类外)
静态成员存储在静态存储区(详见《内存.md》),生命周期贯穿整个程序运行期。在 GSRL 中,静态成员常用于单例模式、全局配置等场景。
6. 类内类型定义
GSRL 代码中常见在类内部定义枚举和类型别名:
class Chassis {
public:
enum ChassisMode : uint8_t {
CHASSIS_NO_FORCE = 0,
FOLLOW_GIMBAL,
SPINNING
};
void setMode(ChassisMode mode) { m_mode = mode; }
private:
ChassisMode m_mode = CHASSIS_NO_FORCE;
};
// 使用
Chassis chassis;
chassis.setMode(Chassis::FOLLOW_GIMBAL); // 通过类名访问
将枚举定义在类内部可以避免全局命名空间污染,并清晰地表达"这个模式枚举属于 Chassis 类"。
7. this 指针
每个非静态成员函数内部都有一个隐式的 this 指针,指向调用该函数的对象:
class Chassis {
public:
Chassis& setSpeed(float vx) {
this->m_vx = vx; // this-> 可以省略
return *this; // 返回对象自身的引用(链式调用)
}
private:
float m_vx;
};
chassis.setSpeed(1.0f).setOmega(0.5f); // 链式调用
8. 与 GSRL 的关系
在 GSRL 中,面向对象被广泛使用:
BTNode是所有行为树节点的基类,BTComposite、BTAction等均继承自它Controller是控制器接口,SimplePID通过公有继承实现该接口Filter<T>是滤波器基类,KalmanFilter、MovingAverage等派生类各自实现滤波算法Chassis、Gimbal是机器人子系统类,封装各自的状态与行为
在嵌入式环境下,GSRL 的类设计遵循以下约束:
- 优先使用静态分配或栈上分配,减少堆上的
new/delete - 成员变量在声明时赋予类内初始值,降低未初始化风险
- 通过
private保护内部状态,仅暴露必要的public接口 - 使用枚举类(
enum)定义模式,而非魔数(magic number)
9. 总结
| 概念 | 说明 |
|---|---|
| 类(Class) | 数据 + 方法的封装体,是对象的模板 |
| 对象(Object) | 类的实例,拥有独立的内存空间 |
| 封装(Encapsulation) | private 数据 + public 接口,控制访问权限 |
| 构造函数 | 对象创建时自动调用,负责初始化 |
| 析构函数 | 对象销毁时自动调用,负责释放资源 |
| 初始化列表 | 在构造函数中直接初始化成员,比函数体内赋值更高效 |
| 三法则 | 自定义析构/拷贝构造/拷贝赋值三者通常需同时定义 |
| 继承 | 子类复用父类的接口与实现(public 继承表示 is-a 关系) |
| 静态成员 | 属于类本身,所有对象共享,存储在静态存储区 |
this 指针 |
指向当前对象的指针,成员函数内部隐式可用 |
继承解决了"代码复用"的问题,但它没有解决"同一接口、不同行为"的问题——这是多态的领域,详见《多态.md》。
作者: Qing | 修改日期: 2026-07-18