构造函数

//入门写法
Point(int xVal, int yVal) {
    x = xVal;  // 默认构造 x(0) -> 赋值 x(xVal)
    y = yVal;  // 默认构造 y(0) -> 赋值 y(yVal)
}

为何这个说是入门的写法,有如下原因:

  • 编译器必须先隐式调用 x 和 y 的默认构造函数(把 0 塞进去),然后再执行 = 进行赋值操作。做了两次多余的工作(先构造,再覆盖)。
  • 语法限制:有些变量“不赋值就没法用”,如果成员是 const 常量、引用(&),或者是没有默认构造函数的自定义类对象,它们必须在定义时被初始化,不能在后面赋值。
    class Player {
        const int id;       // const 常量
        int& gold;          // 引用
    public:
        Player(int uid, int g) {
            id = uid;   // ❌ 报错!const 变量不能被赋值
            gold = g;   // ❌ 报错!引用必须一开始就绑定
        }
    };
    

所以c++提供了一个更高效的写法,这个只针对构造函数有效

Point(int xVal, int yVal) : x(xVal), y(yVal) {}

这个冒号 : 引导的语法叫做“成员初始化列表”

数据类型

基础数据类型

在这里插入图片描述
在这里插入图片描述

派生数据类型

在这里插入图片描述
标准库
在这里插入图片描述

c++11新增数据类型

在这里插入图片描述

动态转换

动态转换是 C++ 中用于在继承层次结构中进行向下转换的一种机制。

为何引入

在面向对象设计中,经常会出现这种情况:

  • 你有一个基类指针(比如 Entity*),它可能指向任意派生类对象(Player*、Monster*、NPC*)。

  • 你想调用派生类特有的方法(比如 Player::useSkill()),但基类接口中没有这个方法。

  • 你不能盲目地用 static_cast 强制转换,因为万一指针指向的不是 Player,程序就会崩溃。

动态转换就是为了解决这个“向下转型”的安全性问题而引入的。​ 它在运行时检查对象的实际类型,如果转换合法则返回有效指针,否则返回 nullptr(对指针)或抛出 std::bad_cast 异常(对引用)。

游戏中引用场景举例

假设你有一个通用的 Task 基类,派生出 AttackTask、MoveTask、HealTask。任务管理器持有 Task* 列表,在处理时需要根据实际类型做不同处理:

class Task {
public:
    virtual ~Task() = default;
    virtual void execute() = 0;
};

class AttackTask : public Task {
public:
    void execute() override { /* 攻击逻辑 */ }
    void setTarget(int targetId) { /* 设置目标 */ }
};

// 在任务调度器中
void dispatch(Task* task) {
    task->execute(); // 虚函数,多态调用,没问题

    // 但如果需要调用 AttackTask 特有的 setTarget()
    AttackTask* atk = dynamic_cast<AttackTask*>(task);
    if (atk) {
        atk->setTarget(123);
    } else {
        // 不是攻击任务,忽略或报错
    }
}

如果没有动态转换,你就得用 typeid 或自己维护类型枚举,代码会变得脆弱且难以维护。

注意事项

  1. 必须有虚函数:dynamic_cast 依赖虚函数表,基类至少要有一个虚函数(通常是虚析构函数)。

  2. 性能开销:运行时类型检查有少量成本,但在游戏后端中通常可接受,不建议在高频循环中使用。

  3. 失败处理:

    • 对指针:转换失败返回 nullptr,一定要检查。

    • 对引用:转换失败抛出 std::bad_cast,需要捕获。

  4. 不要滥用:能用虚函数解决的问题尽量用虚函数,dynamic_cast 意味着设计上可能需要重构(比如违反开闭原则)。

常量转换

常量转换用于将 const 类型的对象转换为非 const 类型的对象。
常量转换只能用于转换掉 const 属性,不能改变对象的类型。

重新解释转换

重新解释转换将一个数据类型的值重新解释为另一个数据类型的值,通常用于在不同的数据类型之间进行转换。

int i = 10;
float f = reinterpret_cast<float&>(i); // 重新解释将int类型转换为float类型

不同转换方式的对比在这里插入图片描述

存储类

为何要有存储类

程序需要对变量的生命周期、作用域和链接性进行精细控制,以满足不同的编程场景。

常用类型

现在市面上基本上都是c++11以上的版本,auto和register基本上很少使用,17版本还废弃了register。所以下面就列出常用的

存储类解决需求典型场景
static​控制变量的生命周期(延长到程序结束)和链接性(内部链接)。• 函数内 static 局部变量:只初始化一次,保存状态。文件作用域 static 全局变量:仅当前文件可见。• 类 static 成员:所有对象共享一份。
extern声明变量在其他编译单元定义,实现跨文件共享。全局配置、日志级别等在多个 .cpp 中访问。
mutable允许在 const 成员函数中修改某些成员变量。缓存、互斥锁、引用计数等逻辑上的“非状态”数据。
thread_local(C++11)每个线程拥有独立的变量副本,生命周期与线程相同。线程局部缓存、每线程随机种子、避免锁争用。

游戏业务中都怎么使用

全局配置

config.h​ – 声明

#pragma once

// 全局配置,在 config.cpp 中定义,其他文件通过 extern 声明访问
struct ServerConfig {
    int port = 8888;
    int maxPlayers = 4096;
};

extern ServerConfig g_serverConfig;  // 声明:告诉编译器这个变量在别处定义

config.cpp​ – 定义

#include "config.h"

// 定义全局配置对象,外部链接,整个项目可见
ServerConfig g_serverConfig = { 8888, 8192 };

玩家管理器

#include <iostream>
#include <thread>
#include "config.h"

// 一个简单的玩家结构
struct Player {
    int id;
    int score;

    // mutable 成员:允许在 const 成员函数中修改
    mutable int cachedExp;  // 缓存的经验值(仅用于演示 mutable)
};

// 创建一个新玩家,并返回指向它的指针
Player* createPlayer(int id, int score) {
    // static 局部变量:只初始化一次,每次调用都会递增
    static int totalPlayers = 0;  // 记录总共创建了多少玩家
    totalPlayers++;

    // 动态分配一个玩家(为了简单,用 new,实际项目中应考虑智能指针)
    Player* p = new Player;
    p->id = id;
    p->score = score;
    p->cachedExp = 0;  // 初始缓存为 0

    std::cout << "Created player #" << totalPlayers
              << " (id=" << id << ", score=" << score << ")\n";
    return p;
}

// 一个 const 成员函数(演示 mutable)
void printPlayerInfo(const Player* p) {
    // 假设我们想缓存一个计算结果,但函数是 const 的
    // 使用 mutable 成员就可以修改
    p->cachedExp = p->score * 10;  // 计算经验值并缓存
    std::cout << "Player " << p->id
              << " score=" << p->score
              << " exp=" << p->cachedExp << "\n";
}

// 线程本地变量:每个线程拥有独立的副本
thread_local int t_threadId = 0;

void workerFunction(int threadId) {
    // 设置当前线程的 ID
    t_threadId = threadId;

    // 使用 extern 全局配置
    std::cout << "Thread " << t_threadId
              << " sees port=" << g_port
              << " maxPlayers=" << g_maxPlayers << "\n";

    // 创建一个玩家(演示 static 局部变量)
    Player* p = createPlayer(threadId * 100 + 1, threadId * 50);

    // 演示 mutable
    printPlayerInfo(p);

    delete p;  // 记得释放内存
}

int main() {
    // 使用 extern 全局配置
    std::cout << "Main thread: server port=" << g_port
              << ", max players=" << g_maxPlayers << "\n";

    // 启动两个工作线程
    std::thread t1(workerFunction, 1);
    std::thread t2(workerFunction, 2);

    t1.join();
    t2.join();

    return 0;
}

运算符

在这里插入图片描述
++和–这两个自增和自减,如果放置的位置不同,产生的效果不一样,下面举例说明

int a = 1;
int b = 2;
int c = a--; // 此时先将a赋值给c,然后自减
int d = --b; // 先自减,再赋值给d
cout << c << endl;
cout << d << endl;

当上面的代码被编译和执行时,它会产生以下结果:

1
1

循环

for循环

  1. 传统下标循环(索引遍历)

最基础的形式,适用于数组或支持 operator[] 的容器(如 vector、string)。

#include <vector>
#include <iostream>

int main() {
    std::vector<int> nums = {10, 20, 30, 40};

    // 传统 for 循环:用索引遍历
    for (int i = 0; i < nums.size(); ++i) {
        std::cout << nums[i] << " ";
    }
    // 输出:10 20 30 40
}

适用场景:需要知道元素下标,或需要同时访问前后元素(如滑动窗口)。

  1. 迭代器循环(C++98 起)
    适用于所有标准库容器(vector、list、map 等),不依赖下标。
#include <vector>
#include <iostream>

int main() {
    std::vector<int> nums = {10, 20, 30, 40};

    // 使用迭代器遍历
    for (std::vector<int>::iterator it = nums.begin(); it != nums.end(); ++it) {
        std::cout << *it << " ";
    }
    // 输出:10 20 30 40
}
  1. 范围 for 循环(C++11 起)
    最简洁、最安全的遍历方式,自动遍历整个容器或数组。
#include <vector>
#include <iostream>

int main() {
    std::vector<int> nums = {10, 20, 30, 40};

    // 范围 for 循环(只读)
    for (int x : nums) {
        std::cout << x << " ";
    }
    // 输出:10 20 30 40

    // 如果需要修改元素,使用引用
    for (int& x : nums) {
        x *= 2;
    }
    // nums 变为 {20, 40, 60, 80}
}
  1. 使用 auto 的类型推导(C++11 起)
    配合范围 for 或迭代器,让代码更通用。
#include <vector>
#include <map>
#include <iostream>

int main() {
    std::vector<int> nums = {1, 2, 3};
    for (auto x : nums) {          // auto 自动推导为 int
        std::cout << x << " ";
    }

    std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 85}};
    for (const auto& kv : scores) {  // kv 类型为 pair<const string, int>&
        std::cout << kv.first << ": " << kv.second << "\n";
    }
}

适用场景:容器元素类型较长或不确定时,用 auto 简化代码。

  1. 使用 std::for_each 算法(C++98 起,C++11 后配合 lambda)
    函数式风格,适合对每个元素执行相同操作。
#include <vector>
#include <algorithm>
#include <iostream>

int main() {
    std::vector<int> nums = {1, 2, 3, 4};

    // 用 lambda 表达式(C++11)
    std::for_each(nums.begin(), nums.end(),
                  [](int x) { std::cout << x << " "; });
    // 输出:1 2 3 4

    // 也可以用函数指针
    void print(int x) { std::cout << x << " "; }
    std::for_each(nums.begin(), nums.end(), print);
}	

适用场景:需要将遍历逻辑抽象为可复用的操作,或配合管道式编程。

  1. 逆向遍历(反向迭代器)
#include <vector>
#include <iostream>

int main() {
    std::vector<int> nums = {10, 20, 30, 40};

    // 使用反向迭代器
    for (auto it = nums.rbegin(); it != nums.rend(); ++it) {
        std::cout << *it << " ";
    }
    // 输出:40 30 20 10

    // 范围 for 不支持直接逆序,但可用 std::reverse 或下标递减
    for (int i = nums.size(); i > 0; --i) {
        std::cout << nums[i - 1] << " ";
    }
}

适用场景:需要从尾部向前遍历。

  1. 多维数组遍历(嵌套循环)
#include <iostream>

int main() {
    int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};

    // 传统嵌套 for
    for (int row = 0; row < 2; ++row) {
        for (int col = 0; col < 3; ++col) {
            std::cout << matrix[row][col] << " ";
        }
        std::cout << "\n";
    }

    // 范围 for 也可用于二维数组(C++11)
    for (const auto& row : matrix) {
        for (int val : row) {
            std::cout << val << " ";
        }
        std::cout << "\n";
    }
}

适用场景:矩阵、图像、棋盘等二维数据结构。

  1. 无限循环
for (;;) {
    // 等价于 while(true),常用于服务器事件循环
    // 需要内部 break 退出
}

适用场景:游戏主循环、事件监听循环,直到收到终止信号才退出。

字符串

推荐使用std::string,不用c风格的字符串。但少量场景使用c风格字符串

  1. 与 C 库函数交互:很多系统 API(如 open、read、write、pthread_create)和标准 C 库函数(printf、sscanf、strtok)都要求 char* 参数,无法直接传 std::string。

  2. 固定大小缓冲区:在网络编程、文件 I/O、协议解析中,常需要预分配固定大小的 char 数组作为缓冲区,避免动态内存分配的开销。

  3. 嵌入式或资源受限环境:可能没有完整的 C++ 标准库支持,只能使用 C 风格字符串。

  4. 性能敏感的热路径:std::string 的动态分配和拷贝有一定开销,在某些高频调用场景下,用栈上分配的 char 数组更可控。

  5. 遗留代码或接口:维护旧项目时,大量代码仍使用 char*,必须兼容。

指针(我一直不太懂的)

指针就是一个存储“内存地址”的变量

想象一排带编号的邮箱(内存单元),每个邮箱有一个唯一的编号(地址),里面可以放东西(数据)。

  • 普通变量:比如 int x = 42; —— 相当于你有一个邮箱,编号是 0x7ffee123,里面放了数字 42。

  • 指针变量:比如 int* ptr = &x; —— 相当于你有一个新的邮箱,里面放的不是数字,而是另一个邮箱的编号(即 x 的地址 0x7ffee123)。

所以指针本身也是一个变量,它存储的是地址,而不是普通的数据值。

常见的使用方式

动态分配对象(堆上创建)

struct Player {
    int id;
    int hp;
};

Player* createPlayer(int id) {
    Player* p = new Player;
    p->id = id;
    p->hp = 100;
    return p;  // 返回指针,调用者负责释放
}

int main() {
    Player* player = createPlayer(1);
    // 使用 player->id, player->hp
    delete player;  // 用完释放
    return 0;
}

说明:new 返回的是对象的地址(指针),通过 -> 访问成员。注意:现代 C++ 更推荐用智能指针(std::unique_ptr),但初学阶段先理解 raw pointer 的原理。

修改函数外部的变量

C++ 默认传参是拷贝,如果想在函数内部修改外部的变量,可以传指针(或引用)。

void addScore(int* score, int delta) {
    *score += delta;  // 通过解引用修改外部变量
}

int main() {
    int total = 0;
    addScore(&total, 10);  // 传入地址
    addScore(&total, 20);
    // total 现在是 30
    return 0;
}

说明:&total 取地址传给指针参数,函数内部用 *score 解引用修改。这是 C 风格的输出参数,C++ 更常用引用,但指针仍然常见。

访问数组元素

数组名在大多数情况下退化为指向第一个元素的指针。

void printArray(int* arr, int size) {
    for (int i = 0; i < size; ++i) {
        std::cout << *(arr + i) << " ";  // 指针算术
    }
}

int main() {
    int data[] = {10, 20, 30};
    printArray(data, 3);  // data 就是指针
    return 0;
}

说明:data 等价于 &data[0],是一个 int*。指针加 i 移动到下一个元素,* 解引用取值。这是 C 风格数组遍历,现代 C++ 更推荐用 std::vector 和迭代器,但理解指针与数组的关系很重要。

实现多态(基类指针指向派生类对象)

面向对象编程中,用基类指针调用虚函数,实现运行时多态。

struct Animal {
    virtual void speak() const { std::cout << "...\n"; }
    virtual ~Animal() = default;
};

struct Dog : Animal {
    void speak() const override { std::cout << "Woof!\n"; }
};

void makeSound(const Animal* animal) {
    animal->speak();  // 根据实际类型调用对应函数
}

int main() {
    Dog dog;
    makeSound(&dog);  // 输出 Woof!
    return 0;
}

说明:Animal* 可以指向 Dog 对象,调用虚函数时动态分派。这是游戏开发中实体组件系统的基石。

遍历链表或树形结构

指针是构建动态数据结构(链表、树、图)的核心。

struct Node {
    int value;
    Node* next;  // 指向下一个节点
};

Node* createList() {
    Node* head = new Node{1, nullptr};
    head->next = new Node{2, nullptr};
    head->next->next = new Node{3, nullptr};
    return head;
}

void printList(Node* node) {
    while (node != nullptr) {
        std::cout << node->value << " ";
        node = node->next;
    }
}

说明:每个节点通过 next 指针链接到下一个,nullptr 表示链表结束。指针在这里起到了“连接”作用。

作为函数返回值

有时函数需要返回一个指针,表示成功或失败,或返回动态创建的对象。

Player* findPlayer(int id) {
    // 假设有一个全局玩家数组
    static Player players[10] = { ... };
    for (int i = 0; i < 10; ++i) {
        if (players[i].id == id)
            return &players[i];  // 返回指向已有对象的指针
    }
    return nullptr;  // 未找到
}

int main() {
    Player* p = findPlayer(5);
    if (p != nullptr) {
        // 使用 p
    }
    return 0;
}

说明:返回指针可以表示“可能存在也可能不存在”,nullptr 表示失败。这是 C 风格的做法,C++ 中也可以用 std::optional 或异常,但指针仍广泛使用。

困扰我的指针问题

多级指针如何理解

假设你有一本通讯录(指针),里面记着朋友家的地址(指向的数据)。现在你想让另一个人修改这本通讯录本身——比如把通讯录换成一本新的,而不是修改里面的地址。

  • 一级指针:你把通讯录的复印件给别人,他只能看,改不了原件。

  • 二级指针:你告诉别人你的通讯录放在哪个抽屉里(指针的地址),他打开抽屉,拿出通讯录,换成一本新的。这样你的原件就被改了。

void func(int* p) {
    // p 是外部指针 ptr 的副本,但 p 存储的地址与 ptr 相同
    *p = 20;          // 修改 p 指向的变量,外部变量 a 也被修改
    p = nullptr;      // 只修改局部副本 p,外部指针 ptr 不受影响
}

如果希望修改指针中的变量,就需要传入双指针参数,我的理解如下;
单指针参数无法通过双重解引用(**p)来修改外部指针的指向,因为:

  • 单指针 p 存储的是变量的地址(比如 &a)。

  • 对 p 解引用一次得到变量 a(类型 int),不是指针,不能再解引用。

  • 要想修改外部指针本身(让它指向别处),必须传入指针的地址(即 int** 或 int*&)。

引用

  • 必须初始化,且不能为 null。
  • 一旦引用被初始化为一个对象,就不能被指向到另一个对象
  • 引用不需要 * 来解引用,直接用就是原对象。

如何和指针区分

  • 指针​ = 一张写着别人家地址的纸条。你可以擦掉地址,写上另一个地址;也可以把纸条烧掉(设为 nullptr);还可以把纸条给别人。

  • 引用​ = 一个人的正式绰号(比如“张三”也叫“阿三”)。绰号永远指同一个人,不能改;而且绰号必须对应一个真实存在的人(不能为空)。

常用场景

函数参数(避免拷贝 + 允许修改)

void swap(int& a, int& b) {   // 引用参数
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 1, y = 2;
    swap(x, y);               // 直接传变量,不需要 & 或 *
    // x=2, y=1
}

好处:调用时不用写 &,函数内部也不用 *,代码更干净。

函数返回值(避免拷贝大对象)

struct BigData { int arr[1000]; };

BigData& getData() {          // 返回引用,避免拷贝
    static BigData data;
    return data;
}

int main() {
    BigData& d = getData();   // d 是 data 的引用,没有拷贝
    d.arr[0] = 42;
}

范围 for 循环中修改容器元素

std::vector<int> nums = {1, 2, 3};
for (int& n : nums) {   // 引用,可以修改原元素
    n *= 2;
}
// nums 变为 {2, 4, 6}

如果不加 &,n 是拷贝,修改不会影响原容器。

什么时候用引用,什么时候用指针?

在这里插入图片描述

数据结构

vector 的三个核心坑

坑 1:扩容导致迭代器失效

vector 扩容时会重新分配内存,所有迭代器、指针、引用全部失效:

std::vector<int> v = {1, 2, 3};
auto it = v.begin() + 1;  // 指向 2
v.push_back(4);           // 可能触发扩容!
std::cout << *it;         // ❌ 未定义行为!it 已失效

解决:push_back 后不要继续使用之前的迭代器,或者在 reserve 后操作。

坑 2:中间插入/删除是 O(n)

vector 中间插入或删除元素,需要移动后续所有元素,复杂度 O(n):

// ❌ 在 vector 头部插入 1000 个元素 = 灾难
for (int i = 0; i < 1000; ++i) {
    v.insert(v.begin(), i);  // 每次都要搬移整个数组
}

// ✅ 如果需要频繁头插,改用 std::deque
std::deque<int> d;
d.push_front(i);  // O(1)

坑 3:遍历删除的正确写法

// ❌ 错误:erase 后 it 失效,++it 是未定义行为
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 30) {
        v.erase(it);  // it 失效!
    }
}

// ✅ 正确:利用 erase 返回的新迭代器
for (auto it = v.begin(); it != v.end(); ) {
    if (*it == 30) {
        it = v.erase(it);  // erase 返回下一个有效迭代器
    } else {
        ++it;
    }
}

std::deque:双端队列

  • 适用:需要在头部和尾部都高效插入删除(O(1))

  • 典型场景:游戏中的消息队列、滑动窗口、撤销栈

  • 注意:内存是分段的,不是一片连续内存,缓存友好性略逊于 vector

std::deque<Message> messageQueue;
messageQueue.push_front(msg);  // 头部插入
messageQueue.push_back(msg);   // 尾部插入

std::list / std::forward_list:链表

  • 适用:需要已知位置的 O(1) 插入删除,且不在意随机访问

  • 典型场景:较少用。游戏后端里偶尔用于需要稳定迭代器(插入删除不使其他迭代器失效)的场景

  • 注意:不支持 [] 随机访问,缓存极差(节点分散在堆上)

⚠️ 实际开发中,list 很少作为默认选择。很多人以为"我插入删除多,用 list",但 vector 的缓存优势 + 批量操作往往更快。

std::map:红黑树(有序)

  • 适用:需要按键排序或范围查询

  • 复杂度:插入/查找/删除 O(log n)

  • 典型场景:按玩家 ID 范围查询、排行榜(需要有序遍历)

std::map<int, Player> players;  // 按 ID 有序
players[1001] = Player(...);    // operator[] 会插入(注意坑 4)

std::unordered_map:哈希表(无序)

  • 适用:高频键值查找,不关心顺序

  • 复杂度:平均 O(1),最坏 O(n)

  • 典型场景:游戏后端最常用!玩家 ID → Player* 映射、配置表加载、缓存

std::unordered_map<int, Player*> playerMap;
playerMap[1001] = new Player(...);  // O(1) 平均
Player* p = playerMap[1001];        // O(1) 平均

unordered_map 的三个坑:

  1. operator[] 会隐式插入:如果 key 不存在,playerMap[key] 会自动插入一个默认值!如果只是想查,用 find():
    // ❌ 危险:即使 key=9999 不存在,也会插入一个默认 Player*
    Player* p = playerMap[9999];  // p 是 nullptr,但 map 里多了一条记录
    
    // ✅ 安全:用 find
    auto it = playerMap.find(9999);
    if (it != playerMap.end()) {
        Player* p = it->second;
    }
    
  2. rehash 导致所有迭代器失效:当元素数量超过负载因子,unordered_map 会 rehash,所有迭代器失效:
    auto it = playerMap.begin();
    playerMap[2002] = new Player(...);  // 可能触发 rehash
    std::cout << it->first;             // ❌ it 可能已失效
    

解决:如果事先知道大概数量,用 reserve() 预留桶:

playerMap.reserve(10000);  // 预留 10000 个桶,减少 rehash
  1. 自定义 key 类型需要提供哈希函数和 operator==:两者必须保持一致,否则查找失败或重复插入。
#include <iostream>
#include <unordered_map>
#include <string>

struct Person {
    std::string name;
    int age;

    // 重载 operator==
    bool operator==(const Person& other) const {
        return name == other.name && age == other.age;
    }
};

// 特化 std::hash 用于 Person
namespace std {
    template<>
    struct hash<Person> {
        std::size_t operator()(const Person& p) const {
            // 同时哈希 name 和 age,保证一致性
            return hash<string>()(p.name) ^ (hash<int>()(p.age) << 1);
        }
    };
}

int main() {
    std::unordered_map<Person, int> salary;  // 现在可以直接用了!

    salary[{ "Alice", 25 }] = 8000;
    salary[{ "Bob", 30 }] = 9500;

    std::cout << salary.size() << "\n";  // 2

    // 查找
    auto it = salary.find({ "Alice", 25 });
    if (it != salary.end())
        std::cout << it->second;  // 8000
}

关键点:std::hash 的特化必须与 operator== 使用的字段一致(这里都是 name 和 age)。如果哈希只用了 name,而 operator== 用了 name 和 age,就会出现之前说的“不一致”问题。

如果不重载 operator==,而是提供独立的相等比较器

有时候不想修改 Person 结构体(比如它是第三方库的),可以单独提供相等比较器:

#include <iostream>
#include <unordered_map>
#include <string>

struct Person {
    std::string name;
    int age;
};

struct PersonHash {
    std::size_t operator()(const Person& p) const {
        return std::hash<std::string>()(p.name) ^ (std::hash<int>()(p.age) << 1);
    }
};

struct PersonEqual {
    bool operator()(const Person& a, const Person& b) const {
        return a.name == b.name && a.age == b.age;
    }
};

int main() {
    // 显式指定四个模板参数
    std::unordered_map<Person, int, PersonHash, PersonEqual> salary;

    salary[{ "Alice", 25 }] = 8000;
    salary[{ "Alice", 26 }] = 8200;  // 不同 age,视为不同 key

    std::cout << salary.size();  // 2
}

注意:PersonEqual 和 PersonHash 必须基于相同的字段(这里是 name 和 age),否则会出现之前说的“哈希冲突但相等比较不一致”的问题。

不一致会导致什么后果?
struct BadHash {
    std::size_t operator()(const Person& p) const {
        return std::hash<std::string>()(p.name);  // 只基于 name
    }
};

struct BadEqual {
    bool operator()(const Person& a, const Person& b) const {
        return a.age == b.age;  // 只基于 age
    }
};

// 插入两个 Person:{"Alice", 25} 和 {"Alice", 27}
// 哈希值相同(因为 name 相同),所以进入同一个桶
// 但相等比较器只比较 age,所以 25≠27,认为它们不同
// 所以 map 里会有两条记录,但 key 都是 "Alice"(不同 age)
// 这破坏了 key 唯一的语义,查找时可能混乱

更多推荐