C++开发常用设计规范

文章目录

  • 一、用指针还是引用
    • [1. 语义上的区别(最重要)](#1. 语义上的区别(最重要))
    • [2. 空值检查(Nullability)](#2. 空值检查(Nullability))
    • [3. 重新赋值(Reassignment)](#3. 重新赋值(Reassignment))
    • [4. 初始化时机](#4. 初始化时机)
    • 5.为什么不推荐单例模式
    • [💡 总结与最佳实践](#💡 总结与最佳实践)
  • 二、对外提供同名重载函数,类型转换锁死在cpp文件实现
  • 三、类间关系
    • [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.为什么不推荐单例模式

  1. 依赖注入困难UserLoginRecord 强依赖于 DbService。如果使用单例,通常需要在单例内部再搞一个 init(DbService&) 方法,这会导致"两阶段初始化",极易引发在初始化前误调用的 Bug。
  2. 单元测试不友好 :单例的全局状态会让测试变得非常困难。如果想在测试中替换一个 Mock 的 DbService,单例模式几乎无法做到。
  3. 生命周期管理 :正如代码中使用了 static DbService,单例的销毁顺序和依赖关系往往是个坑。

💡 总结与最佳实践

在现代 C++ 的类设计中,关于如何持有外部依赖,有一个不成文的黄金法则:

  1. 如果依赖是必须的,且不需要更换 👉 使用引用(DbService&
    *
    • UserLoginRecord 必须依赖 DbService,所以用引用最完美。*
  2. 如果依赖是可选的(可能为空) 👉 使用指针(DbService*
    • 例如:一个可选的日志记录器,如果没有配置,就不记录。
  3. 如果依赖是必须的,且需要转移所有权 👉 使用智能指针(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++ 工程(如之前写的 UserLoginRecordDbService)中,如何抉择?

  1. 能用"依赖"就不用"关联" :如果 UserLoginRecord 只需要在某个瞬间调用一下 Logger 打印日志,直接把 Logger 作为参数传进去即可,不要把 Logger 变成 UserLoginRecord 的成员变量。
  2. 需要长期合作,用"关联" :如果 UserLoginRecord 在整个生命周期内都需要频繁调用 DbService,那就把 DbService 作为成员变量。为了体现"非拥有"语义,可以使用引用或裸指针(遵循 Core Guidelines R.3)。
  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 对所有权和参数传递有极其严格的规范,旨在消除内存泄漏和悬空指针:

  1. R.3: A raw pointer (a T) is non-owning (裸指针是非拥有者) *
    • 含义 :如果你看到一个裸指针,你应该默认它只是"借用"了资源,绝对不要对它执行 delete 操作。
  2. R.11: Avoid calling new and delete explicitly (避免显式调用 new 和 delete)
    • 含义 :现代 C++ 中应完全避免使用 newdelete。需要独占资源时使用 std::unique_ptr,需要共享时使用 std::shared_ptr
  3. R.30: Take smart pointers as parameters only to explicitly express lifetime semantics (仅在明确表达生命周期语义时,将智能指针作为参数)
    • 含义 :如果函数只是需要"使用"一个对象,参数应该传 const T&T&。只有当函数需要"接管/转移"资源时,才传 std::unique_ptr;只有当函数需要"参与共享管理"时,才传 std::shared_ptr
  4. C.67: A polymorphic class should suppress copying (多态类应禁止拷贝)
    • 含义:这涉及到对象语义的控制(Rule of Three/Five/Zero)。如果你在设计一个管理资源的类,必须显式控制它的拷贝、移动和析构行为,防止意外的浅拷贝导致"双重释放(Double Free)"。

掌握这些所有权语义,就能在 C++ 中写出既安全又易于维护的代码。建议在日常开发中,时刻思考:"这个资源的生命周期由谁负责?"

五、在类型转换中返回UNKNOWN 或 INVALID 项

最佳实践是在枚举中显式定义一个无效值(Invalid/Unknown),并在转换失败时返回它。

1.修改枚举定义

ActionType 中增加一个 UNKNOWNINVALID 项:

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; // 安全且语义明确的兜底
}
  1. 类型不匹配ActionType 是一个强类型的枚举类(enum class),而 0 是一个整型字面量。在 C++11 及之后的标准中,enum class 不允许隐式转换为整数,直接 return 0; 会导致编译错误
  2. 缺乏语义 :即使强制转换成功(如 static_cast<ActionType>(0)),在业务逻辑上 0 代表什么?是代表 BITRATE_SWITCH(因为它是枚举的第一项),还是代表"未知/无效"?这会引发严重的业务 Bug。