文章目录
- 一、用指针还是引用
-
- [1. 语义上的区别(最重要)](#1. 语义上的区别(最重要))
- [2. 空值检查(Nullability)](#2. 空值检查(Nullability))
- [3. 重新赋值(Reassignment)](#3. 重新赋值(Reassignment))
- [4. 初始化时机](#4. 初始化时机)
- 5.为什么不推荐单例模式
- [💡 总结与最佳实践](#💡 总结与最佳实践)
- 二、对外提供同名重载函数,类型转换锁死在cpp文件实现
-
- [1. 在头文件中声明两个重载函数](#1. 在头文件中声明两个重载函数)
- [2. 在 CPP 文件中的实现](#2. 在 CPP 文件中的实现)
- [3. 外部调用方如何使用](#3. 外部调用方如何使用)
- 4.总结
- 三、类间关系
-
- [1. 依赖 (Dependency) ------ "借用"](#1. 依赖 (Dependency) —— “借用”)
- [2. 关联 (Association) ------ "认识 / 合作"](#2. 关联 (Association) —— “认识 / 合作”)
- [3. 组合 (Composition) ------ "拥有 / 包含"](#3. 组合 (Composition) —— “拥有 / 包含”)
- [💡 总结与实战建议](#💡 总结与实战建议)
- 四、C++的所有权语义
-
- [1. 独占所有权 (Exclusive Ownership)](#1. 独占所有权 (Exclusive Ownership))
- [2. 共享所有权 (Shared Ownership)](#2. 共享所有权 (Shared Ownership))
- [3. 弱所有权 / 观察者 (Weak Ownership)](#3. 弱所有权 / 观察者 (Weak Ownership))
- [4. 借用 / 无所有权 (Non-owning Reference)](#4. 借用 / 无所有权 (Non-owning Reference))
- [C++ Core Guidelines 中的相关定义](#C++ Core Guidelines 中的相关定义)
- [五、在类型转换中返回UNKNOWN 或 INVALID 项](#五、在类型转换中返回UNKNOWN 或 INVALID 项)
一、用指针还是引用
场景:在ClientSessionManager和MessageHandler这两个类的实现中都要调用UserLoginRecord的addUserActions方法,在main函数中数据是这样定义的
static DbService db_service;
static std::unique_ptr client_session_manager;
static std::unique_ptr message_handler;
同时需要把DbService传入给UserLoginRecord,在哪里初始化UserLoginRecord对象比较好,是在main函数中,还是采用单例
在现代 C++ 中,引用(&)和指针(*)在底层实现上几乎是一样的(通常都是一个内存地址),但在语义表达 和使用规范上有显著的区别。
针对场景(在类中持有外部传入的依赖对象,如 DbService),强烈推荐使用引用(DbService&)。以下是它们的核心区别:
1. 语义上的区别(最重要)
- 引用(
&):代表**"强绑定"和"必须存在"**。它向阅读代码的人明确宣告:"这个对象是我正常工作所必需的,它一定不为空,并且在我的整个生命周期内都不会改变指向。" - 指针(
*) :代表**"可选"或"可变"**。它暗示:"这个对象可能为空(nullptr),或者在运行过程中可能会指向另一个对象。"
2. 空值检查(Nullability)
- 引用 :在 C++ 标准中,引用不能为空 (尽管通过一些黑客手段可以制造空引用,但这属于未定义行为 UB)。因此,在使用
db_service_时,不需要 每次都写if (db_service_ != nullptr),代码更简洁。 - 指针:理论上可以为空。如果用指针,严谨的代码在每次使用前都需要检查它是否为空,否则会有崩溃的风险。
3. 重新赋值(Reassignment)
- 引用 :一旦在构造函数中初始化,就永远不能再指向别的对象。这保证了依赖关系的绝对稳定。
- 指针 :可以随时被重新赋值(例如
db_service_ = &another_service;)。如果业务逻辑不需要在运行时更换数据库服务,使用指针反而留下了误操作的风险。
4. 初始化时机
- 引用:必须在构造函数的初始化列表中进行初始化。这迫使在创建对象时就提供依赖,避免了"两阶段初始化"的麻烦。
- 指针 :可以在构造函数内部赋值,或者提供一个
setDbService(DbService* db)的方法稍后赋值。
5.为什么不推荐单例模式
- 依赖注入困难 :
UserLoginRecord强依赖于DbService。如果使用单例,通常需要在单例内部再搞一个init(DbService&)方法,这会导致"两阶段初始化",极易引发在初始化前误调用的 Bug。 - 单元测试不友好 :单例的全局状态会让测试变得非常困难。如果想在测试中替换一个 Mock 的
DbService,单例模式几乎无法做到。 - 生命周期管理 :正如代码中使用了
static DbService,单例的销毁顺序和依赖关系往往是个坑。
💡 总结与最佳实践
在现代 C++ 的类设计中,关于如何持有外部依赖,有一个不成文的黄金法则:
- 如果依赖是必须的,且不需要更换 👉 使用引用(
DbService&)
*UserLoginRecord必须依赖DbService,所以用引用最完美。*
- 如果依赖是可选的(可能为空) 👉 使用指针(
DbService*)- 例如:一个可选的日志记录器,如果没有配置,就不记录。
- 如果依赖是必须的,且需要转移所有权 👉 使用智能指针(
std::unique_ptr<DbService>)
*main函数中用unique_ptr管理对象是正确的,因为main拥有这些对象的所有权。但作为被注入的依赖,其他类只"使用"它,不"拥有"它,所以用引用。*
一句话总结 :用引用(&)是在表达**"契约"(我保证它一定在),用指针(*)是在表达 "可能性"**(它可能存在,也可能不存在)。场景属于强契约,所以 DbService& 是最优雅的选择。
这种设计模式叫做依赖注入(Dependency Injection)。
- 清晰 :谁依赖谁,在
main函数的构造顺序里一目了然。 - 安全:编译器会强制要求传入依赖,不存在"忘记初始化单例"的风险。
- 解耦 :
ClientSessionManager不需要知道UserLoginRecord是不是单例,它只知道"有人给了我一个能用的对象"。
二、对外提供同名重载函数,类型转换锁死在cpp文件实现
开发需求:外部操作类型同时包含uint_8和std::string类型,传入后的uint_8需要转换为std::string。
希望将所有的行为记录逻辑(包括系统宏的翻译)都高内聚 地封装在 UserLoginRecord 类内部,让外部调用方尽量简单。
如何对外提供函数接口、内部如何设计?
既然 addUserActions 接收字符串,而外部有时传入的是数字宏,最优雅且符合 C++ 面向对象设计的做法是:在 UserLoginRecord 类中提供一个同名但参数不同的重载函数(Overload)。
这样既保证了核心逻辑不重复,又让外部调用方极其方便。以下是具体的实现方案:
1. 在头文件中声明两个重载函数
cpp
class UserLoginRecord {
public:
// 核心函数:接收字符串,负责所有的内存聚合、JSON构建和数据库写入
void addUserActions(const std::string& username, uint8_t client_type,
const std::string& action_str,
const std::string& terminal_number, uint8_t channel_no);
// 重载函数:接收系统宏定义(uint8_t),内部将其转换为字符串后,转发给核心函数
void addUserActions(const std::string& username, uint8_t client_type,
uint8_t system_action,
const std::string& terminal_number, uint8_t channel_no);
};
2. 在 CPP 文件中的实现
cpp
// 1. 核心逻辑保持不变(只处理字符串)
void UserLoginRecord::addUserActions(const std::string& username, uint8_t client_type,
const std::string& action_str,
const std::string& terminal_number, uint8_t channel_no) {
// ... 原有的内存聚合、JSON构建、阈值判断等逻辑 ...
}
// 2. 内部翻译函数(仅在本文件内可见,不对外暴露)
static std::string trans_system_action_to_string(uint8_t system_action) {
switch (system_action) {
case SYS_PTZ_UP: return "PTZ_UP";
case SYS_PTZ_DOWN: return "PTZ_DOWN";
case SYS_PTZ_LEFT: return "PTZ_LEFT";
case SYS_PTZ_RIGHT: return "PTZ_RIGHT";
case SYS_ZOOM_IN: return "ZOOM_IN";
case SYS_ZOOM_OUT: return "ZOOM_OUT";
default: return "UNKNOWN_ACTION";
}
}
// 3. 重载函数的实现:只做翻译,然后转发(Delegate)
void UserLoginRecord::addUserActions(const std::string& username, uint8_t client_type,
uint8_t system_action,
const std::string& terminal_number, uint8_t channel_no) {
std::string action_str = trans_system_action_to_string(system_action);
// 防御性校验:未知动作直接丢弃,不进入核心逻辑
if (action_str == "UNKNOWN_ACTION") {
// 可选:记录一条警告日志
return;
}
// 【核心】将参数原封不动地转发给处理字符串的核心函数
// 这样所有的核心业务逻辑(加锁、内存聚合、写库)都只在核心函数里维护一份
addUserActions(username, client_type, action_str, terminal_number, channel_no);
}
3. 外部调用方如何使用
现在,无论外部调用方手里拿的是什么类型,都直接调用 addUserActions 即可,C++ 编译器会根据参数类型自动选择正确的函数:
cpp
// 场景 A:云台控制,传入数字宏
login_record.addUserActions(username, client_type, SYS_PTZ_UP, "CAM01", 1);
// 场景 B:切换码流,传入字符串
login_record.addUserActions(username, client_type, "BITRATE_SWITCH", "CAM01", 1);
4.总结
函数设计原则 (F - Functions)
- 避免宏和全局状态,提高封装性 (F.15 等) :
将trans_system_action_to_string定义为文件作用域的static函数,将其作为类的内部实现细节隐藏起来,外部模块完全无法访问。这减少了全局命名空间的污染和副作用。
类型安全原则 (Type - Type Safety)
- 保持强类型语义 :
通过std::string传递业务动作(如"BITRATE_SWITCH"),而不是在外部强行把字符串转成uint8_t再传入,保证了业务语义的完整性和类型安全。
接口与类设计原则 (I - Interfaces / C - Classes)
- 保持重载操作符/函数的自然语义 (C.161 / I.14) :
C++ 核心指南建议,重载函数应该具有相同的功能,仅在输入参数类型不同时使用。两个addUserActions功能完全一致,仅仅是参数类型的适配,这符合指南中"仅在输入参数类型不同、功能相同时重载函数"的规范。 - 接口设计的松耦合 (I.11 / I.14) :
对外暴露统一的addUserActions接口,将底层系统宏(SYS_PTZ_UP)到业务字符串的映射封装在内部。调用方不需要知道内部是如何转换的,实现了极好的解耦。
三、类间关系
- 依赖:通常作为函数参数传入(用完即走,耦合度最低)。
- 关联:作为类的成员变量(通常是引用或指针,生命周期独立)。
- 组合 :作为类的值类型成员变量(同生共死,强绑定)。
在 C++ 面向对象设计中,依赖(Dependency) 、**关联(Association)和组合(Composition)**是描述类与类之间耦合程度的核心概念。它们体现了从"最弱"到"最强"的耦合关系。
1. 依赖 (Dependency) ------ "借用"
概念 :一个类(A)在某个方法中临时使用了另一个类(B)的对象。A 不需要长期持有 B,只是"用完即走"。
生活比喻 :你(类A)去餐厅点了一份外卖,外卖小哥(类B)把饭给你,你吃完就扔了。你不需要一直把外卖小哥留在家里。
代码特征 :作为函数的局部变量 或函数参数传入。
cpp
// 厨师类
class Chef {
public:
void cook() { /* 做饭 */ }
};
// 顾客类
class Customer {
public:
// 依赖关系:Customer 依赖 Chef,但只在 eat 方法执行期间需要 Chef
// 耦合度最低,Customer 不持有 Chef 的成员变量
void eat(Chef& chef) {
chef.cook();
}
};
2. 关联 (Association) ------ "认识 / 合作"
概念 :一个类(A)将另一个类(B)的对象作为自己的成员变量。A 知道 B 的存在,但 A 和 B 的生命周期是相互独立 的。A 被销毁时,B 不一定被销毁。
生活比喻 :你和你的同事。你们互相认识(有关联),但你离职了(被销毁),你的同事依然还在公司(生命周期独立)。
代码特征 :作为类的成员变量,通常使用指针 、引用 或智能指针。
cpp
// 员工类
class Employee {
public:
std::string name;
Employee(std::string n) : name(n) {}
};
// 部门类
class Department {
private:
// 关联关系:部门持有一个指向员工的指针
// 部门被销毁时,员工可能还在,可能被分配到其他部门
Employee* manager_;
public:
Department(Employee* mgr) : manager_(mgr) {}
~Department() {
// 注意:这里不能 delete manager_,因为部门不拥有员工的生命周期
}
};
3. 组合 (Composition) ------ "拥有 / 包含"
概念 :一个类(A)将另一个类(B)作为自己的核心组成部分,A 完全拥有 B 的生命周期。A 和 B 是"同生共死"的关系。A 被销毁时,B 必然被销毁。
生活比喻 :你和你的大脑。你(类A)被销毁(死亡)了,你的大脑(类B)作为你的一部分,自然也就不复存在了。
代码特征 :作为类的成员变量,通常使用值类型(对象本身)或 独占智能指针(std::unique_ptr)。
cpp
// 引擎类
class Engine {
public:
void start() { /* 启动 */ }
};
// 汽车类
class Car {
private:
// 组合关系:汽车完全拥有引擎的生命周期
// 汽车被销毁时,引擎必然被销毁
Engine engine_;
public:
Car() { /* 引擎随汽车一起自动构造 */ }
~Car() {
// 引擎会在 Car 析构后自动析构,无需手动释放
}
};
💡 总结与实战建议
在实际的 C++ 工程(如之前写的 UserLoginRecord 和 DbService)中,如何抉择?
- 能用"依赖"就不用"关联" :如果
UserLoginRecord只需要在某个瞬间调用一下Logger打印日志,直接把Logger作为参数传进去即可,不要把Logger变成UserLoginRecord的成员变量。 - 需要长期合作,用"关联" :如果
UserLoginRecord在整个生命周期内都需要频繁调用DbService,那就把DbService作为成员变量。为了体现"非拥有"语义,可以使用引用或裸指针(遵循 Core Guidelines R.3)。 - 强绑定,用"组合" :如果
UserLoginRecord内部需要一个专门用来缓冲数据的ActionBuffer对象,这个 Buffer 纯粹是为UserLoginRecord服务的,那就直接用组合(ActionBuffer buffer_;),让 C++ 编译器自动帮你管理内存。
理清这三种关系,代码架构就会变得非常清晰,内存管理也会变得极其安全!
四、C++的所有权语义
在现代 C++ 中,所有权语义(Ownership Semantics) 是资源管理的核心概念。它明确定义了"谁负责管理资源(如内存、文件句柄、网络连接等)的生命周期",以及"当对象被复制、移动或销毁时,它的行为规则是什么"。
现代 C++ 极力推崇 RAII(资源获取即初始化) 原则,将资源的生命周期与对象的生命周期绑定。为了在代码中直接、显式地表达所有权意图,C++ 提供了智能指针和移动语义等机制。
以下是 C++ 中几种核心的所有权模型、代码示例以及对应的 C++ Core Guidelines 规范:
1. 独占所有权 (Exclusive Ownership)
概念 :同一时刻,资源只能被一个实体(对象)拥有。当该实体被销毁时,资源也会被自动释放。不允许复制,但支持通过移动语义(std::move)转移所有权。
典型代表 :std::unique_ptr
代码示例:
cpp
#include <memory>
#include <iostream>
class Resource {
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main() {
// 独占所有权
std::unique_ptr<Resource> ptr1 = std::make_unique<Resource>();
// 转移所有权给 ptr2,ptr1 变为空指针
std::unique_ptr<Resource> ptr2 = std::move(ptr1);
// ptr1 已经失去所有权,不能再访问资源
if (!ptr1) {
std::cout << "ptr1 is now empty\n";
}
return 0;
} // ptr2 离开作用域,自动销毁 Resource
2. 共享所有权 (Shared Ownership)
概念 :允许多个实体共同拥有同一个资源。内部维护一个"引用计数器",每次复制时计数加 1,销毁时计数减 1。只有当最后一个拥有者被销毁(计数归零)时,资源才会被释放。
典型代表 :std::shared_ptr
代码示例:
cpp
#include <memory>
#include <iostream>
int main() {
auto sp1 = std::make_shared<int>(42);
{
auto sp2 = sp1; // 复制:引用计数变为 2
std::cout << "use_count: " << sp1.use_count() << '\n'; // 输出 2
} // sp2 销毁,引用计数变为 1,int 对象依然存活
std::cout << "use_count: " << sp1.use_count() << '\n'; // 输出 1
return 0;
} // sp1 销毁,引用计数归零,释放内存
3. 弱所有权 / 观察者 (Weak Ownership)
概念 :对共享所有权管理的对象进行"非拥有式"的引用。它不会增加引用计数,因此无法延长资源的生命周期。常用于打破 shared_ptr 之间的循环引用,或者作为观察者安全地检测对象是否仍然存活。
典型代表 :std::weak_ptr
代码示例:
cpp
#include <memory>
#include <iostream>
int main() {
auto sp = std::make_shared<int>(9);
std::weak_ptr<int> wp = sp; // 观察 sp,引用计数不增加
// 安全地访问对象
if (auto locked = wp.lock()) {
std::cout << "Object exists: " << *locked << '\n';
}
return 0;
}
4. 借用 / 无所有权 (Non-owning Reference)
概念:仅仅是对资源的引用(借用),不负责资源的释放。在 C++ 中,原始指针(Raw Pointer)和引用(Reference)默认代表非拥有语义。
C++ Core Guidelines 中的相关定义
C++ Core Guidelines 对所有权和参数传递有极其严格的规范,旨在消除内存泄漏和悬空指针:
- R.3: A raw pointer (a T) is non-owning (裸指针是非拥有者) *
- 含义 :如果你看到一个裸指针,你应该默认它只是"借用"了资源,绝对不要对它执行
delete操作。
- 含义 :如果你看到一个裸指针,你应该默认它只是"借用"了资源,绝对不要对它执行
- R.11: Avoid calling new and delete explicitly (避免显式调用 new 和 delete)
- 含义 :现代 C++ 中应完全避免使用
new和delete。需要独占资源时使用std::unique_ptr,需要共享时使用std::shared_ptr。
- 含义 :现代 C++ 中应完全避免使用
- R.30: Take smart pointers as parameters only to explicitly express lifetime semantics (仅在明确表达生命周期语义时,将智能指针作为参数)
- 含义 :如果函数只是需要"使用"一个对象,参数应该传
const T&或T&。只有当函数需要"接管/转移"资源时,才传std::unique_ptr;只有当函数需要"参与共享管理"时,才传std::shared_ptr。
- 含义 :如果函数只是需要"使用"一个对象,参数应该传
- C.67: A polymorphic class should suppress copying (多态类应禁止拷贝)
- 含义:这涉及到对象语义的控制(Rule of Three/Five/Zero)。如果你在设计一个管理资源的类,必须显式控制它的拷贝、移动和析构行为,防止意外的浅拷贝导致"双重释放(Double Free)"。
掌握这些所有权语义,就能在 C++ 中写出既安全又易于维护的代码。建议在日常开发中,时刻思考:"这个资源的生命周期由谁负责?"
五、在类型转换中返回UNKNOWN 或 INVALID 项
最佳实践是在枚举中显式定义一个无效值(Invalid/Unknown),并在转换失败时返回它。
1.修改枚举定义
在 ActionType 中增加一个 UNKNOWN 或 INVALID 项:
cpp
enum class ActionType {
UNKNOWN = -1, // 明确定义一个无效值
BITRATE_SWITCH, // 码流切换
PTZ_UP,
PTZ_DOWN,
PTZ_LEFT,
PTZ_RIGHT,
ZOOM_IN, // 加大焦距
ZOOM_OUT // 减小焦距
};
2. 修改转换函数
将 return 0; 替换为 return ActionType::UNKNOWN;:
cpp
ActionType trans_string_to_ActionType(const std::string& action_type) {
if (action_type == "BITRATE_SWITCH") return ActionType::BITRATE_SWITCH;
else if (action_type == "PTZ_UP") return ActionType::PTZ_UP;
else if (action_type == "PTZ_DOWN") return ActionType::PTZ_DOWN;
else if (action_type == "PTZ_LEFT") return ActionType::PTZ_LEFT;
else if (action_type == "PTZ_RIGHT") return ActionType::PTZ_RIGHT;
else if (action_type == "ZOOM_IN") return ActionType::ZOOM_IN;
else if (action_type == "ZOOM_OUT") return ActionType::ZOOM_OUT;
else return ActionType::UNKNOWN; // 安全且语义明确的兜底
}
- 类型不匹配 :
ActionType是一个强类型的枚举类(enum class),而0是一个整型字面量。在 C++11 及之后的标准中,enum class不允许隐式转换为整数,直接return 0;会导致编译错误。 - 缺乏语义 :即使强制转换成功(如
static_cast<ActionType>(0)),在业务逻辑上0代表什么?是代表BITRATE_SWITCH(因为它是枚举的第一项),还是代表"未知/无效"?这会引发严重的业务 Bug。