参考资料
迭代
迭代是编程中的基础概念,而 C++ 提供了许多技术来迭代集合,每种技术都有其细微差别和用例。
传统循环
基本的 For 循环:当迭代次数事先已知时常用。
for (int i = 0; i < vec.size(); ++i) {
// 访问 vec[i]
}
While 和 Do-While 循环:当迭代次数不是由索引严格定义,或者循环至少必须运行一次时使用。
// While 循环
int i = 0;
while (i < vec.size()) {
// 访问 vec[i]
++i;
}
// Do-While 循环
int j = 0;
do {
// 访问 vec[j]
++j;
} while (j < vec.size());
基于指针的迭代:直接访问内存,通常与 C 风格的数组一起使用。
int arr[5] = {1, 2, 3, 4, 5};
for (int* p = arr; p != arr + 5; ++p) {
// 访问 *p
}
基于 STL 的迭代
基于迭代器的 For 循环:使用容器定义的迭代器直接访问元素。
for (auto it = vec.begin(); it != vec.end(); ++it) {
// 访问 *it
}
基于范围的 For 循环:C++11 引入,用于简化容器遍历。
for (auto& value : vec) {
// 访问 value
}
使用函数指针和仿函数的 std::for_each:在 C++11 的 lambda 函数之前,这些是常用的方法。
// 使用函数指针
void process(int value);
std::for_each(vec.begin(), vec.end(), process);
// 使用仿函数
struct MyFunctor {
void operator()(int value) {
// 处理 value
}
};
std::for_each(vec.begin(), vec.end(), MyFunctor());
使用 Lambda 的 std::for_each:C++11 之后,lambda 函数提供了一种更简洁的使用 std::for_each 的方式。
std::for_each(vec.begin(), vec.end(), [](int& value) {
// 处理 value
});
带迭代器的算法:<algorithm> 中的 STL 算法在迭代器范围上操作。
// 示例:std::transform
std::transform(vec.begin(), vec.end(), vec.begin(), [](int v) { return v * 2; });
并行迭代:C++17 引入了并行算法,用于多线程处理。
std::for_each(std::execution::par, vec.begin(), vec.end(), process);
虽然传统的循环机制仍然是相关的,尤其是对于更简单的任务或与内存紧密合作时,基于 STL 的技术提供了强大、表现力强和通常更易读的方式来迭代集合。选择技术应由任务的具体要求、所需的代码清晰度和性能考虑来驱动。
并发
并发涉及设计和构建系统来管理多个任务,这些任务可能重叠或交错。C++ 的发展显著地增加了并发支持功能。
- 并发: 针对多个任务的设计中心管理。 It’s more about managing many things at once.
- 并行: 任务执行以提高性能。It’s about doing many things at once.
C++11 之前,标准库
- 缺少内置并发支持。
- 依赖于特定平台的库或
Boost.Thread。
C++11 并发功能
内存模型: 使多线程场景下的内存行为易于理解。
线程 (<thread>): 提供直接、可访问的方法在不同线程中运行函数。
#include <iostream>
#include <thread>
void print_hello() {
std::cout << "来自另一个线程的问候!" << std::endl;
}
int main() {
std::thread t(print_hello);
t.join();
std::cout << "来自主线程的问候!" << std::endl;
}
互斥体 (<mutex>): 保证数据完整性,避免数据竞争。
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int shared_data = 0;
void increment() {
for (int i = 0; i < 1000; ++i) {
mtx.lock();
++shared_data;
mtx.unlock();
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "共享数据值: " << shared_data << std::endl;
}
条件变量 (<condition_variable>): 基于特定条件同步线程。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void print_message() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; });
std::cout << "来自工作线程的问候!" << std::endl;
}
int main() {
std::thread t(print_message);
std::cout << "设置就绪标志..." << std::endl;
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one();
t.join();
}
原子操作 (<atomic>): 对共享数据进行无锁操作。
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> count(0);
void increment() {
for (int i = 0; i < 1000; ++i) {
++count;
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "计数值: " << count.load() << std::endl;
}
未来 (<future>): 异步运行函数并稍后检索结果。
#include <iostream>
#include <future>
int compute() { return 42 * 42; }
int main() {
std::future<int> result = std::async(compute);
std::cout << "执行其他任务..." << std::endl;
std::cout << "结果: " << result.get() << std::endl;
}
C++17 及以后
并行算法: 无需手动线程管理即可获得最佳性能。
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {5, 3, 4, 1, 2};
std::sort(std::execution::par, v.begin(), v.end());
for (int i : v) {
std::cout << i << " ";
}
}
并发 TS 和协程: 线性、可读的异步代码。
// 注意: 实际使用可能需要编译器和标准库对协程的支持。
#include <iostream>
#include <coroutine>
#include <future>
std::future<int> compute() {
co_return 42 * 42;
}
int main() {
auto result = compute();
std::cout << "结果: " << result.get() << std::endl;
}
线程 std::thread
std::thread 是 C++11 中引入的标准库类,用于管理线程。它允许代码并行执行。在 C++11 之前,通常使用平台特定的库或第三方库来实现线程。
- 构造函数:通过为
std::thread的构造函数提供一个函数(或可调用的对象)来启动一个新线程。这个线程将立即开始执行所提供的函数。
std::thread myThread(functionName);
- join():用于等待线程完成其执行的方法。当你调用
join()时,调用线程(通常是主线程)将阻塞,直到std::thread对象完成其执行。
myThread.join();
- detach():此方法允许线程在后台运行,让调用线程继续执行,而无需等待分离的线程完成。一旦线程被分离,它完成执行后,其资源将自动被释放。
myThread.detach();
当使用 detach() 方法时,必须小心。如果一个分离的线程访问已经被销毁的资源,它会导致未定义的行为。
这种情况是多线程编程中的一个常见陷阱,被称为“竞态条件” (race condition) 。
示例:
void someFunction() {
int someLocalVariable = 42;
std::thread t([&](){
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << someLocalVariable << std::endl; // 危险!
});
t.detach();
} // 这里 someLocalVariable 超出范围并被销毁
在上述示例中,分离的线程在 someLocalVariable 被销毁后尝试访问它,导致未定义的行为。
- joinable():检查线程是否可连接。如果线程仍在运行且尚未被连接或分离,那么线程是可连接的。在其
std::thread对象被销毁之前,必须连接或分离一个可连接的线程,以防止程序终止。
if(myThread.joinable()) {
myThread.join();
}
- hardware_concurrency():返回一个估计的数字,表示系统可以并行运行的线程数。
unsigned int n = std::thread::hardware_concurrency();
使用线程时,重要的考虑因素:
- 总是确保在其关联的
std::thread对象被销毁之前,要么join线程,要么detach线程,以防止程序终止。 - 当多个线程访问共享数据时,同步是至关重要的。使用
std::mutex等同步机制来防止竞态条件并确保数据的一致性。 - 理解线程和它们访问的资源的生命周期是至关重要的。线程访问已销毁的资源可能导致未定义的行为和潜在的程序崩溃。
- 在多线程环境中使用线程安全的数据结构和实用程序以获得更好的安全性。
原子操作 std::atomic
在多线程程序中,std::atomic 为变量提供了原子(不可中断)操作,以防止在并发上下文中的数据竞争。
基本原子操作:std::atomic 的主要用途是保证对封装的变量进行原子操作。这包括赋值、读取、增加等操作。
std::atomic<int> counter(0);
counter++; // 原子增加
int value = counter.load(); // 原子读取
对于非原子变量,我们需要用 mutex 保护它,来达到 thread-safe.
std::atomic<int> atomicCount(0);
int nonAtomicCount = 0;
std::mutex mtx;
void process() {
++atomicCount;
mtx.lock();
++nonAtomicCount;
mtx.unlock();
}
内存顺序:std::atomic 提供了控制操作的内存顺序的更高级特性。这允许开发者在特定场景中优化线程之间的同步。
counter.fetch_add(1, std::memory_order_relaxed);
复杂原子操作:除基本操作外,std::atomic 提供了更复杂的原子操作,如 compare_exchange_weak 和 compare_exchange_strong。
int expected = 10;
bool success = atomicVar.compare_exchange_strong(expected, 20);
原子标志:std::atomic_flag 是一个专门的原子类型,作为一个自旋锁。
std::atomic_flag flag = ATOMIC_FLAG_INIT;
while (flag.test_and_set(std::memory_order_acquire)); // 自旋直到标志清除
// 临界区
flag.clear(std::memory_order_release);
专门的原子类型:C++ 标准库为整数、指针和其他常见类型提供了专门的原子类型,如 std::atomic_int, std::atomic_long, std::atomic_bool 和 std::atomic<void*>。
总之,std::atomic 不仅仅是改变变量的行为,它提供了丰富的特性,允许开发者创建精密的多线程算法和数据结构。
函数
Lambda 函数
Lambda 函数在 C++11 中被引入,它提供了一种简洁的方式直接在函数体或类方法中定义匿名(无名)函数。
[capture_clause](参数) -> 返回类型 {
// lambda 函数体
}
- capture_clause: 指定哪些外部变量对于 lambda 是可用的。
- 参数: 像常规函数参数一样。
- 返回类型: 通常由编译器自动推导,但可以明确指定。
- 函数体: 当调用 lambda 时要执行的代码。
使用捕获子句 (capture clause) 可以使来自封闭作用域的变量在 lambda 内部可用。
[=]: 通过值捕获所有本地变量。[&]: 通过引用捕获所有本地变量。[x, &y]: 通过值捕获x并通过引用捕获y。
在不同源代码中共享函数
如果我们想要在我们的「项目」下的「不同源代码」里共用一个函数,我们可以通过下面的三个步骤完成
1 - 将函数的定义写进「头文件」
#ifndef FUNCTIONS_H_INCLUDED
#define FUNCTIONS_H_INCLUDED
int add(int a, int b); // Function prototype, its declaration
#endif
(其中的 #ifndef 之类的东西叫做 include gard,用来避免 重复引用头文件 造成的 对同一个函数的 多次定义。注意,include gard 只在「同一个」.cpp 源文件内起作用。)
2 - 在「第一个」源文件里定义函数
#include "functions.h"
// Function definition
int add(int a, int b)
{
return a + b;
}
3 - 在「其他源文件」中 include 头文件,使用函数
#include <iostream>
#include "functions.h"
int main()
{
std::cout << "add(1, 2) = " << add(1, 2) << '\n';
}
传递指针给函数
在 C 语言里,传递指针(譬如 *num)的语法如下:
void triple(int *num) {
*num = *num * 3;
}
C++ 里可以做同样的事情,不过也可以传递地址(譬如 &num)
void triple(int &num) {
num = num * 3;
}
没有变量名的参数
在某些特殊的情况下,我们会写出下面的函数
double one(double) { return 1.0; }
这个函数 的输入 是一个小数,输出 永远为 1.0。此时,输入的参数 并不会进入 实际的计算,所以我么不需要给它 变量名。
内存
在 C++ 中,内存通常分为四个段:
- 栈(Stack):用于自动变量和函数调用信息。
- 堆(Heap):用于动态分配的内存。
- 数据段(Data Segment):用于全局和静态变量。
- 代码段(Code Segment):用于可执行代码。
动态内存分配
在 C++ 中,使用 new 关键字从堆中分配内存。例如:
double *p = new double[4];
你也可以使用变量来动态指定数组的大小:
int n = 4;
double *p = new double[n];
初始化堆内存中的数组也是可能的:
int *p = new int[4] {1, 2, 3, 4};
动态分配的内存需要手动释放,以防止内存泄漏(Memory Leak)。
delete p // 释放单个对象
delete[] p // 释放对象数组
如果不释放内存,程序可能会消耗所有可用内存,最终被操作系统终止。
指针不知道内存的长度
使用 new 分配的内存,会返回一个指针,但该指针不包含关于分配内存大小的信息。这可能导致越界访问(Out-of-Range Access),这是一种常见的、难以调试的错误。譬如,
double * p = new double[2]
在 heap 区域里分配了 2 个 double 占用的内存,并且返回一个指向这一小块内存的指针 p。但是,指针不知道自己指向的内存的长度。我们可以通过 p 访问我们不应该访问的内存。
... | p[-2] | p[-1] | p[0] | p[1] | p[2] | p[3] | ...
一个避免 out-of-range access 的方法是使用 vector。
自定义类型的内存分配
对于具有默认构造函数的自定义类型 X:
X* px1 = new X; // 单个实例
X* px2 = new X[10]; // 数组
对于没有默认构造函数的自定义类型 Y:
Y* py1 = new Y {1}; // 单个实例
Y* py2 = new Y[5] {1, 2, 3, 4, 5}; // 数组
内存分页
内存分页是一种内存管理方案,主要用于虚拟内存系统。在这种方案中,物理内存被划分为固定大小的块,称为页(pages)。同样地,虚拟内存也被划分为相同大小的页。操作系统维护一个页表(page table)来映射虚拟页到物理页。
内存分页有下面的好处,
- 内存保护: 分页允许操作系统为每个进程提供独立的地址空间,从而实现内存隔离。
- 动态内存分配: 分页简化了内存分配,使得小块内存可以更容易地被分配和回收。
- 虚拟内存: 通过分页,操作系统可以使用磁盘空间作为虚拟内存,从而扩展可用的内存空间。
当程序访问一个虚拟地址时,硬件和操作系统会一起查找相应的页表,以确定该虚拟地址对应的物理地址。这个过程称为页转换(page translation)。
虽然分页主要是操作系统级别的概念,了解它对于理解如何优化 C++ 程序以减少“页面错误(page faults)”是有用的。
std::vector<int> largeVector(1e6); // 特别大的数组
for (int i = 0; i < largeVector.size(); ++i) {
largeVector[i] = i; // 页面错误可能在这里发生,导致性能下降
}
Valgrind
Valgrind 是一个开源的内存调试、内存泄漏检测和性能分析工具。它最初由 Julian Seward 开发,并于 2000 年首次发布。该工具主要用于 Linux 和 macOS 系统,尽管也有一些不太成熟的 Windows 版本。它包含下面的功能,
- 内存泄漏检测: Valgrind 的 Memcheck 工具可以检测内存泄漏、未初始化的内存访问以及其他内存相关错误。
- 性能分析: 使用 Cachegrind 和 Callgrind 工具,您可以分析代码的缓存使用和调用图。
- 并发错误检测: Helgrind 和 DRD 工具用于检测多线程程序中的数据竞争条件。
基本使用: 使用 Valgrind 运行 C++ 程序通常如下:
valgrind ./your_program
检查内存泄漏: 使用 Memcheck 工具:
valgrind --tool=memcheck ./your_program
性能分析: 使用 Cachegrind 工具:
valgrind --tool=cachegrind ./your_program
Valgrind 是一个非常强大的工具,对于内存调试和性能分析非常有用。了解其基本用法和功能可以帮助您更有效地进行 C++ 开发。
深入多态
多态是面向对象编程中的一个核心概念,它允许对象被当作其父类的实例,而不是它们实际的类。在 C++ 中,有两种类型的多态:编译时(静态)多态和运行时(动态)多态。
编译时多态
编译时多态在编译过程中通过诸如函数重载、操作符重载和模板等机制得到解决。编译器根据参数类型和参数数量确定适当的函数调用。函数重载示例如下
void print(int i) {
cout << "打印整数: " << i << endl;
}
void print(double f) {
cout << "打印浮点数: " << f << endl;
}
运行时多态
运行时多态通过继承和虚函数实现。它允许一个函数根据函数正在操作的实际对象的类型来执行不同的操作。下面的代码展示了基类和派生类示例
class Animal {
public:
virtual void speak() { cout << "某种动物的声音" << endl; }
};
class Cat : public Animal {
public:
void speak() override { cout << "喵" << endl; }
};
当你创建一个 Cat 对象并将其当作 Animal 对待时,会调用 Cat 的 speak 方法,演示了运行时多态。
派生类的内存布局在开始处包含基类的布局,这允许基类指针正确地引用派生类对象。内存布局如下面的 ASCII 图所示
Cat* ptr = new Cat();
+------------------+
| Animal 部分 | <- ptr 指向这部分的开始
|------------------|
| - Animal 数据 | <- 基类数据成员
+------------------+
| Cat 部分 |
|------------------|
| - Cat 数据 | <- 派生类数据成员
+------------------+
ptr 指向 Cat 对象的开始,这包括 Animal 部分,因为派生类对象的布局是以基类的布局开始的。
虚函数表用于在运行时解决函数调用。每个有虚函数的类都有自己的 vtable,如下面的 ASCII 图所示
+------------------+
| Animal 类的 |
| vtable |
+------------------+
| Animal::speak() | <- 指向 Animal 的 speak 函数
+------------------+
+------------------+
| Cat 类的 |
| vtable |
+------------------+
| Cat::speak() | <- 指向 Cat 重写的 speak 函数
+------------------+
这个 vtable 机制确保了基于对象的实际类型调用正确的函数。
指针
- 声明指向类型的指针
类型 * 指针名 - 获取指针的内容 (dereference)
* 指针名 - 我们也可以用
指针名[位移]来取得地址的内容,[]叫 subscript operator。 指针名[0]等价于* 指针名- 获取一个对象的地址
& 对象名,&叫 address of operator - 声明类的指针:
ClassName * pointerName。 - 通过指针访问成员:
pointerName->member。
使用 sizeof 函数可以获取 不同对象/指针 占用的内存,下面是一些结果
uint8_t: 1 // 数据占用的内存
double: 8 // 数据占用的内存
*uint8_t: 8 // 指针占用的内存
*double: 8 // 指针占用的内存
声明
我们可以先「给一个变量赋值」,再「得到指向这个变量的指针」
int main() {
int a = 1;
int * pa = &a;
}
如果我们想要「先创建一个指针」,接着「给指针指向的内存赋值」,直觉上我们会写出这样的代码
// 这段代码一定不会编译成功
int main() {
int * pa; // 没有初始化,得到一个随机地址;这样的代码永远不应该出现
* pa = 1;
}
上面的代码首先生成了一个「随机的地址」,之后向这个地址写入了 1。这样是不对的,因为我们有可能改变程序里其他对象的值。
不过,如果我们主动声明「指针指向的变量」,即确定「指针的地址」,那么我们就可以给这个地址赋值了。
// 可以编译成功
int main() {
int * pa;
int a;
pa = &a;
*pa = 1;
}
还有一种方法是使用 new 主动分配内存
int main() {
int * pa = new int; // 得到一个 heap 里的随机地址
*pa = 1;
}
null 指针
如果我们在 创建指针的时候 暂时没有一个「用于初始化」的地址,那么我们应该用 nullptr 来 初始化这个指针
double * p0 = nullptr;
这会将 p0 的地址设定为 0x0。
此外,不同的编译器 对于 没有初始化的指针 有不同的处理。苹果的 clang11 会将 没有初始化的指针 自动设置为 nummptr 但是 g++9.2 不会。
我们可以用 下面两种方法 判断 指针 是否属于 nullptr;我们会这么做的原因是,有的时候我们需要 构建一个指针——它在 某种情况下 有效,指向某个地址;在 另外的情况下 无效,指向 nullptr。
if (p != nullptr); // 第一种
if (p); // 第二种,推荐
this 指针
this只能在类的成员函数内部使用。this是一个指向当前对象的指针。
示例
int FOO::is_self (FOO &object){
return (&object == this) ? 1 : 0;
}
智能指针
- C++中的智能指针允许在不使用
new和delete的情况下使用指针。 - 需要包含头文件:
<memory> - 智能指针的类型:
std::unique_ptrstd::shared_ptrstd::weak_ptr
std::unique_ptr更受欢迎因为它的开销较小,但需要小心管理作用域。
独占指针(Unique Pointer)
管理单个对象,并在指针超出作用域时自动销毁对象。构建 unique pointer 的代码如下:
std::unique_ptr<E> e(new E());
std::unique_ptr<E> e = std::make_unique<E>(); // protect against exception
其中,当我们直接使用 new 创建 std::unique_ptr 时,实际上进行了两个步骤:
- 为
E分配内存。 - 调用
E的构造函数。
如果我们的程序在
- 内存分配之后、构造函数调用之前,或
- 构造函数调用期间
抛出异常,会导致内存泄漏,因为分配的内存尚未分配给智能指针。相比之下,std::make_unique 在单个操作中处理分配和构造。如果在此过程中发生异常,std::make_unique 将自动清理(即释放)内存,从而防止内存泄漏。换言之,因为内存分配和构造函数调用被封装在同一个原子操作内,所以没有间隙可以让异常留下未被智能指针管理的已分配内存。
共享指针(Shared Pointer)
通过引用计数来管理对象,有轻微的内存开销。 示例:
std::shared_ptr<E> e1 = std::make_shared<E>();
std::shared_ptr<E> e2 = e1; // e1 和 e2 管理同一个对象
弱指针(Weak Pointer)
复制shared_ptr但不增加引用计数。 示例:
std::shared_ptr<Entity> e1 = std::make_shared<Entity>();
std::weak_ptr<Entity> e0 = e1; // e0引用由e1管理的对象
面向对象
结构体和类
在C++中,结构体(Structs)和类(Classes)非常相似,主要区别在于成员的默认访问修饰符:结构体默认为public,而类默认为private。
选择使用结构体还是类通常取决于是否需要对成员数据施加特定的约束或规则(Invariants)。如果成员数据需要满足某些条件,使用类是更好的选择。
以日期容器为例,日期的整数值需要满足特定规则:必须在1到31之间。由于这个约束,更适合使用类来实现。在C++中,这种约束通常被称为不变式(Invariants)。
访问修饰符
在C++中,类的成员(包括数据成员和成员函数)有三种访问修饰符(Access Modifiers):
private:仅同一个类的成员函数或友元类(friend class)可以访问。protected:除了private的权限外,子类也可以访问。public:任何地方都可以访问。
类的默认的访问修饰符是private。
通过关键字 friend ,我们可以访问类的 private 或 protected 成员,如下面的例子所示
class Foo {
private:
int a = 0;
friend void a_plus_one(Foo &f);
};
运算符的读法
*x:pointed by x(由x指向的)&x:address of x(x的地址)x.y:member y of object x(对象x的成员y)(*x).y:member y of object pointed by x(由x指向的对象的成员y)x->y:等价于(*x).yx[0]:first object pointed by x(由x指向的第一个对象)x[1]:second object pointed by x(由x指向的第二个对象)x[n]:$n+1$th object pointed by x(由x指向的第$n+1$个对象)
构造函数
- 构造函数的声明格式为
ClassName::ClassName。 - 构造函数没有返回值,也不用声明
void。 - 构造函数可以被重载(Overloading)。
- 类似于在Python中的
__init__方法。
现代C++推荐使用初始化列表(Initialization List):
Foo f{arg_1, arg_2};
而不是传统的:
Foo f(arg_1, arg_2);
析构函数
- 拥有资源的类应该有一个析构函数(Destructor)。
- 在Python中,相当于
__del__方法。 - 析构函数的声明格式为
ClassName::~ClassName。
在C++中,拥有资源的类(Resource-owning class)通常指的是类内部管理着一些需要手动释放的资源,这些资源通常是在堆(heap)上分配的内存(通常通过 new 或 new[] 运算符),或者其他需要显式释放的系统资源(如文件句柄、网络连接等)。
例如:
class ResourceOwner {
private:
int* data;
public:
ResourceOwner(int size) {
data = new int[size];
}
~ResourceOwner() {
delete[] data;
}
};
在这个例子中,ResourceOwner 类是一个”拥有资源的类”,因为它在堆上分配了一个整数数组,并在其析构函数中释放了这个资源。
运算符重载
运算符重载(Operator Overloading)的格式:
ReturnType ClassName::operator Symbol (ParameterType parameterName)
静态成员
- 静态成员不依赖于具体的对象。
- 静态成员必须在类定义之外进行定义。
静态成员(Static Members)在C++中有多种使用场景:
共享数据:当你希望类的所有实例共享同一份数据时,可以使用静态成员变量。例如,你可能想要跟踪一个类的所有实例数量。
class MyClass {
public:
static int instanceCount;
MyClass() {
instanceCount++;
}
~MyClass() {
instanceCount--;
}
};
int MyClass::instanceCount = 0;
工具函数:当一个函数与类有关,但不依赖于类的实例状态时,可以将其声明为静态成员函数。这样的函数只能访问静态成员变量。
class MathUtils {
public:
static double squareRoot(double x);
};
单例模式:静态成员常用于实现单例模式,确保一个类只有一个实例。
class Singleton {
private:
static Singleton* instance;
Singleton() {}
public:
static Singleton* getInstance() {
if (!instance) {
instance = new Singleton();
}
return instance;
}
};
Singleton* Singleton::instance = nullptr;
常量:当你需要一个与类关联的常量,但不希望为每个实例都存储一份时,可以使用静态成员。
class Circle {
public:
static const double PI;
};
const double Circle::PI = 3.14159;
缓存和优化:静态成员可以用于存储那些计算代价高昂但不频繁变化的数据,以提高程序性能。
继承
- 子类(Derived Class)继承父类(Base Class)的格式:
class DerivedClass: AccessSpecifier BaseClass。 - C++支持多重继承。
示例
class A {
public:
int x;
protected:
int y;
private:
int z;
};
class B : public A {
// x is public
// y is protected
// z is not accessible
};
虚函数
在C++中,虚函数(Virtual Functions)主要用于实现多态性(Polymorphism),特别是运行时多态性(Runtime Polymorphism)。以下是一些常见的应用场景:
接口抽象:当我们有多个派生类(Derived Classes),并且它们共享相同的基类接口时,可以使用虚函数。这允许我们通过基类指针或引用来操作不同的派生类对象。
class Shape {
public:
virtual void draw() { /* Default Implementation */ }
};
class Circle : public Shape {
public:
void draw() override { /* Circle-specific Implementation */ }
};
动态分发:虚函数允许在运行时确定应调用哪个派生类的方法,而不是在编译时。
Shape* shape = new Circle();
shape->draw(); // Calls Circle::draw() at runtime
模板方法模式:在这种设计模式中,基类定义了一个算法的框架,而将一些步骤的具体实现延迟到派生类中。
class Algorithm {
public:
void execute() {
step1();
step2(); // Virtual function
step3();
}
virtual void step2() = 0; // Pure virtual function
};
资源释放:虚析构函数(Virtual Destructor)用于确保当删除基类指针时,派生类的析构函数也会被调用,从而正确地释放资源。
class Base {
public:
virtual ~Base() { /* Resource cleanup */ }
};
修饰符
在使用 C++ 语言编写程序的时候,我们可以通过修饰符,来指定变量、函数或类的一些特性或行为。这些修饰符可以影响编译器如何解释代码,或者如何生成机器代码。
修饰符类别
修饰符包括下面的种类,
- 存储类说明符:用于描述变量或函数的存储期限、可见性和生命周期,如
static:用于指定变量、函数或类成员的存储期限和可见性,使其在程序的整个生命周期内保持状态。auto: 用于自动推断变量的类型。这在 C++11 及以后的版本中特别有用。register: 这个关键字用于建议编译器将变量存储在寄存器中以加速访问,但现代编译器通常会自动进行这种优化,所以这个关键字现在基本上是废弃的。extern:用于声明一个变量或函数是在其他文件中定义的。thread_local:C++11 引入,用于指定一个变量是每个线程独有的。
- 类型限定符:用于描述变量的类型属性,如
const:用于声明变量或对象的状态不可更改。volatile:用于告诉编译器不要优化与该变量有关的代码。这通常用于多线程或硬件交互的情况。mutable: 在一个const成员函数中,mutable关键字允许你修改成员变量。
- 函数修饰符: 用于描述成员函数的特性,如
inline:用于建议编译器内联一个函数,即将函数调用替换为函数体。virtual:用于声明一个函数为虚函数,允许在派生类中重写它。override:用于明确指出派生类中的函数是重写基类中的虚函数。final:阻止进一步的继承(如果用于类)或重写(如果用于虚函数)。explicit:用于阻止不应发生的隐式类型转换。
static
在 C++ 中,static 关键字有多个使用场景,具体取决于它出现的上下文。以下是主要的使用场景:
静态局部变量:在函数内部,static 用于声明静态局部变量。这些变量在第一次函数调用时初始化,并在程序的整个生命周期内保持其值,如下面的代码所示:
void counter() {
static int count = 0;
count++;
std::cout << count << std::endl;
}
静态成员变量:在类中,static 用于声明静态成员变量。这些变量属于类本身,而不是类的任何特定实例,如下面的代码所示:
class MyClass {
public:
static int staticVar;
};
int MyClass::staticVar = 0; // 初始化
静态成员函数:在类中,static 也可以用于声明静态成员函数。这些函数可以直接通过类名调用,而不需要类的实例,如下面的代码所示:
class MyClass {
public:
static void staticFunction() {
// ...
}
};
静态全局变量和函数(文件作用域):在全局作用域中,static 限制变量或函数的可见性,使其只在定义它的文件内可见,如下面的代码所示:
// File: main.cpp
static int staticGlobalVar = 42; // 只在 main.cpp 内可见
static void staticGlobalFunction() {
// ...
}
静态类(匿名命名空间):在 C++17 中,我们可以使用静态类(通常在匿名命名空间内)来限制类的可见性,如下面的代码所示:
namespace {
static class StaticClass {
// ...
};
}
const
在C++的面向对象编程中,const 主要用于增加类型安全和程序可读性,同时也有助于优化。其主要的功能如下,
- 不可变性:
const用于声明变量或对象的状态不可更改。 - 编译时检查:
const允许编译器在编译时进行安全检查。
这个关键字的使用场景如下,
成员函数:在成员函数后添加 const 关键字,表示该函数不会修改调用它的对象的状态。这样的函数称为 const 成员函数。示例如下
class MyClass {
public:
void NonConstFunction() { /* Can modify object state */ }
void ConstFunction() const { /* Cannot modify object state */ }
};
注意,即使在const function中,mutable修饰的成员变量也可以被修改。
对象实例:用 const声明的对象实例不能调用非 const 成员函数。
const MyClass obj;
obj.NonConstFunction(); // Compilation error
obj.ConstFunction(); // OK
对象实例:用 const 声明的对象实例不能调用非 const 成员函数。
void MyFunction(const MyClass& obj) {
// Cannot modify obj
}
返回值:返回 const 类型可以防止对函数返回值的非法修改。
class MyClass {
public:
void Modify() { /* modify object */ }
};
const MyClass CreateObject() {
return MyClass();
}
int main() {
MyClass obj = CreateObject(); // OK
CreateObject().Modify(); // Compilation error
}
静态成员变量:类中的静态成员变量经常被声明为 const,以表示它们是不可变的。
class MyClass {
public:
static const int staticValue = 42;
};
inline
inline 关键字在 C++ 中有多个用途和考虑因素。
性能优化:inline 的一个主要用途是性能优化。当函数被标记为 inline,编译器会尝试将函数的代码直接嵌入到每个调用点,从而减少函数调用的开销。然而,这只是一个给编译器的“建议”,对于复杂或较长的函数,编译器可能会忽略这个建议。
避免重复定义:inline 还用于解决链接阶段的重复定义问题。这在模板函数和在头文件中定义的普通函数中尤为重要。如果一个函数(模板或普通函数)在头文件中定义,并且这个头文件被多个源文件包含,那么标记该函数为 inline 可以避免链接时的重复定义错误。例如:
// math_utils.h
#pragma once
template <typename T>
T square(T x) {
return x * x;
}
然后在两个不同的源文件中:
// main1.cpp
#include "math_utils.h"
int main() {
int result = square<int>(5);
return 0;
}
// main2.cpp
#include "math_utils.h"
int main() {
int result = square<int>(6);
return 0;
}
在这种情况下,如果 square 函数没有被标记为 inline,链接器会发现两个相同的 square<int> 函数定义,从而导致重复定义错误。使用 inline 可以解决这个问题。
头文件中的函数:虽然通常不推荐在头文件中定义普通函数,但在某些特定场景(如小型工具函数或模板函数)下,这是可接受的。在这种情况下,inline 是必要的,以确保多个编译单元中的函数定义在链接阶段被视为同一实例。
编译器自动内联:值得注意的是,现代编译器通常会自动内联一些小函数,即使没有明确地使用 inline 关键字。但依赖编译器的自动内联并不能解决重复定义的问题。
综合来说,inline 是一个多用途的关键字,用于性能优化和代码组织。它既可以作为编译器优化的建议,也可以作为避免链接时错误的工具。在使用 inline 时,应明确其目的,并根据具体情况权衡利弊。
优化
汇编代码
通过 GCC 的 -S 编译选项,我们可以输出 C++ 代码对应的汇编代码,例如:
g++ -S -g -O3 -march=native -std=c++17 src.cc
在网站 Compiler Explorer 中,我们可以轻松地查看 C++ 代码在经过不同的编译器选项之后,得到的汇编代码。
#include <iostream>
#include <algorithm>
#include <cfloat> // for FLT_MAX
float find_min(float* arr1, float* arr2, int n) {
float min_val = FLT_MAX;
for (int i = 0; i < n; ++i) {
float temp = arr1[i] + arr2[i];
min_val = std::min(min_val, temp);
}
return min_val;
}
int main() {
float arr1[] = {1.2, 2.3, 3.4, 4.5};
float arr2[] = {5.6, 6.7, 7.8, 8.9};
int n = sizeof(arr1) / sizeof(arr1[0]);
float min_val = find_min(arr1, arr2, n);
std::cout << "The minimum value is: " << min_val << std::endl;
return 0;
}
对于函数 find_min,其对应的汇编代码是
find_min(float*, float*, int):
testl %edx, %edx
jle .L5
movslq %edx, %rdx
vmovss .LC0(%rip), %xmm1
salq $2, %rdx
xorl %eax, %eax
.L4:
vmovss (%rdi,%rax), %xmm0
vaddss (%rsi,%rax), %xmm0, %xmm0
addq $4, %rax
vminss %xmm1, %xmm0, %xmm1
cmpq %rax, %rdx
jne .L4
vmovaps %xmm1, %xmm0
ret
.L5:
vmovss .LC0(%rip), %xmm1
vmovaps %xmm1, %xmm0
ret
其中的一些变量解释如下,
%edx: 存储整数参数(int n)。%rdi,%rsi: 存储指向浮点数组的指针(float* arr1,float* arr2)。%xmm0,%xmm1: 用于浮点运算的SSE寄存器。%rax,%rdx: 通用寄存器。
整个代码执行了下面的操作:
- 初始化和边缘情况处理
testl %edx, %edx: 检查n是否为零或负数。jle .L5: 如果n为零或负数,跳转到.L5。movslq %edx, %rdx: 将n移动到%rdx并符号扩展。vmovss .LC0(%rip), %xmm1: 用FLT_MAX初始化%xmm1。salq $2, %rdx: 将%rdx乘以4(字节偏移)。xorl %eax, %eax: 将%eax设置为0(循环计数器)。
- 循环操作(.L4)
vmovss (%rdi,%rax), %xmm0: 将arr1[i]加载到%xmm0。vaddss (%rsi,%rax), %xmm0, %xmm0: 将arr2[i]添加到%xmm0。addq $4, %rax: 将%rax增加4(下一个浮点数)。vminss %xmm1, %xmm0, %xmm1: 计算最小值。cmpq %rax, %rdx: 比较%rax和%rdx。jne .L4: 如果不相等,跳回.L4。
- 返回值
vmovaps %xmm1, %xmm0: 将结果移动到%xmm0。ret: 返回。
- 边缘情况返回(.L5)
vmovss .LC0(%rip), %xmm1: 用FLT_MAX初始化%xmm1。vmovaps %xmm1, %xmm0: 将结果移动到%xmm0。ret: 返回。
线性访问
- 硬件预取(Hardware Prefetching)机制会自动预加载连续的内存地址,减少从主内存到缓存的数据传输。
- 非线性访问可能导致缓存失效(Cache Miss),需要重新从主内存加载数据,这通常比从缓存加载要慢得多。
- 线性访问数组元素可以显著提高缓存利用率,从而提高程序性能。
优化策略
- 数据重组:如果可能,重新组织数据以便能够进行线性访问。
- 循环重排:调整嵌套循环的顺序,使内层循环能够线性访问数据。
代码示例
// 线性访问
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
// 线性访问 array[i][j]
}
}
// 非线性访问
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
// 非线性访问 array[j][i]
}
}
其他话题
最令人困惑的解析
『最令人困惑的解析』这个术语是由 Scott Meyers 在他的书 “Effective STL” 中普及。
它指的是当编译器必须在将一个语句解释为
- 函数声明, 或者
- 变量声明
二者之一时,出现的语法模糊性。它是 C++ 语言中的一个特点,对程序员来说可能是一个混淆的源头。
让我们看一个此模糊性的经典示例:
class A {};
class B {
public:
B(A a) {}
};
int main() {
B b(A());
}
你可能会期望 B b(A()); 定义了一个类型为 B 的对象 b,并用类型为 A 的匿名对象初始化它(即调用构造函数 B(A a))。
但是,C++ 对此有不同的解释:它将这一行解释为声明一个名为 b 的函数,该函数接受一个函数指针(没有参数并返回一个 A)并返回一个 B。
问题出现在括号上。C++ 语法的构造是这样的,当它看到 B b(A()); 时,它更倾向于将其解释为函数声明,而不是带有构造函数调用的变量声明。这种行为与编译器以尽可能通用的方式解析语句的偏好是一致的。
要消除这种模糊性,有几种方法:
- 使用额外的括号集:
B b((A()));通过这样做,你明确表示你不是在声明一个函数。
- 使用统一的初始化(C++11 及以后版本):
B b{A()};随着 C++11 通过大括号引入的统一初始化,这种模糊性得到了解决,因为函数声明不使用大括号。
- 创建一个命名的
A实例:A a; B b(a);
随着 C++11 引入的『统一初始化』语法,这个问题已经被解决。但理解它可以帮助解读旧的 C++ 代码,或理解 C++ 语法的一些复杂之处。
头文件里放什么
一个 C++ 的项目里 通常有很多 头文件 (.h, .hpp 甚至 .tpp) 和 代码文件 (.cpp)。什么东西应该被放进 头文件 (head file) 而什么应该被放进 代码文件 (code file) 呢?
我在 Stackoverflow 上看到了一个 很好的答案。简而言之,
- 在头文件里,我们放入「很多文件 共同需要的 信息」,即 抽象定义。
- 在代码文件里,我们放入「只被这个文件需要的 信息」,即 具体实现。
std::map
std::map里,每个「元素的类型」是pair,pair.first= 键;pair.second= 值。
std::map 可以用
for (auto item : map_instance) { ... ;}
的格式迭代。其中,item 的类型是 pair。我们可以用 item.first 获取 键;用 item.second 获取 值。一个完整的例子如下
#include <iostream>
#include <map>
using namespace std;
int main() {
map<string, int> m;
m["apple"] = 1;
m["orange"] = 2;
for (auto item : m) {
cout << item.first << " : " << item.second << endl;
}
}
Vim 配置
我编写 C++ 代码的时候,习惯使用 vim 作为编辑器,搭配插件 ale 和 YouCompleteMe。前者是作用是 检查代码语法,后者的作用是 代码的自动补全。在插件 ale 进行 语法检查 的时候,我们需要「告诉」vim 我们的编译选项。由于 不同项目使用的 编译选项 不同,所以我倾向于 在不同项目下 使用不同的 vim 配置。具体的做法如下。
首先,向系统的 VIM 配置文件 (vimrc) 添加下面两行代码。大部分 Linux 系统下,这个文件处于 $HOME/.vimrc 或者 $HOME/.vim/vimrc。
set exrc
set secure
上面的 两行代码 能够让 vim 读取「当前的工作文件夹」下的 配置文件。其次,在 C++ 项目的文件夹(即,我们的工作文件夹)里,创建一个 命名为 .vimrc 的文件,加入下面的代码。
let g:ale_linters = {'cpp': ['clang']} " use clang compiler
let cpp_flags = '-std=c++11 -Wall ' " c++ standard & show all warnings
let g:ale_cpp_clang_options = cpp_flags " set the flag for linting
我们可以在 cpp_flags 里加入更多的参数,来满足项目的需要。下面的 .vimrc 文件是我在使用 Eigen 库时使用的参数。
let g:ale_linters = {'cpp': ['clang']} " use clang compiler
let cpp_flags = '-std=c++11 -Wall ' " c++ standard & show all warnings
let cpp_flags = cpp_flags . '-I/usr/local/include/eigen3' . ' ' " for eigen
let cpp_flags = cpp_flags . '-Wno-unknown-warning-option' . ' ' " supress eigen warnings
let g:ale_cpp_clang_options = cpp_flags " set the flag for linting