C++经验谈
C++ 的经验之谈
一、用异或来交换变量是错误的
翻转一个字符串,例如把“12345”变成“54321”。
1 | // 版本一,用中间变量交换两个数 |
1 | // 版本二,用异或运算交换两个数 |
C++ 对翻转字符有更简单的解法—调用 STL 里的 std::reverse() 函数。现在的编译器会把 std::reverse() 这种简单函数自动内联展开。
1 | // 版本三,用 std::reverse 颠倒一个区间 |
二、不要重载全局 ::operator new()
内存管理的基本要求
既不重复 delete,也不漏掉 delete。new/delete 要配对,不仅个数相等,还隐含了 new 和 delete 的调用本身要匹配 。
- 用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放。
- 用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放。
- 用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放。
- 用系统默认的 operator new() 分配的内存要交给系统默认的 ::operator delete() 去释放。
- 用 placement new 创建的对象要用 placement delete 去析构(其实就是直接调用析构函数)。
- 从某个内存池 A 分配的内存要还给这个内存池。
- 如果定制 new/delete,要按《Effective C++ 中文版》第8章“定制 new 和 delete”来。
重载 ::operator new() 的理由
- 检测代码中的内存错误;
- 优化性能;
- 获得内存使用的统计数据。
::operator new() 的两种重载方式
不改变其签名,无缝直接替换系统原有的版本
1
2
3
4
void* operator new(size_t size);
void operator delete(void* p);用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是不需要看见这两个函数声明。“性能优化”通常用这种方式。
增加新的参数,调用时也提供这些额外的参数
1
2
3
4
5
6
7
8// 此函数返回的指针必须能被普通的 ::operator delete(void*) 释放
void* operator new(size_t size, const char* file, int line);
// 此函数只在构造函数抛出异常的情况下才会被调用
void operator delete(void* p, const char* file, int line);
// 使用方式
Foo* p = new(__FILE, __LINE__) Foo; // 这样能跟踪是哪个文件哪一行代码分配的内存也可以用宏替换 new 来节省打字。使用方需要看到这两个函数声明,也就是要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。
现实的开发环境
编写稍具规模的 C++ 应用程序,会用到一些 library。可以分为如下几大类:
- C 语言的标准库,也包括 Linux 编程环境提供的 glibc 系列函数。
- 第三方的 C 语言库,例如 OpenSSL。
- C++ 语言的标准库,主要是 STL。
- 第三方的通用 C++ 库,例如 Boost.Regex,或者 XML 库。
- 公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
- 本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。
重载 ::operator new() 的困境
1、规则1:绝对不能在 library 里重载 ::operator new()
如果 library 要提供给别人使用,那么你无权重载全局 ::operator new(size_t)(注意这是前面提到的第一种重载方式),因为这非常具有侵略性,任何用到你的 library 的程序都被迫使用了你重载的 ::operator new(),而别人可能不愿意这么做。另外,如果有两个 library 都试图重载 ::operator new(size_t),估计会发生 duplicated symbol link error。
2、第二种重载方式如何?
首先,void* operator new(size_t size, const char* file, int line); 这种方式得到的 void* 指针必须能同时被 void operator delete(void) 和 void operator delete(void p, const char* file, int line) 这两个函数释放。
其次,在 library 里重载 void* operator new(size_t size, const char* file, int line) 还涉及你的重载要不要暴露给 library 的使用者(其他 library 或主程序)。
- 包含你的头文件的代码会不会用到你重载的 ::operator new()
- 重载之后的 ::operator new() 分配的内存能不能在你的 library 之外被安全地释放。如果不行,那么是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回 shared_ptr,利用其捕获析构动作的特性?
在主程序里重载 ::operator new() 的作用不大
C++ library 在代码组织上有两种形式:
- 以头文件方式提供(如以 STL 和 Boost 为代表的模板库);
- 以头文件 + 而二进制库文件方式提供(大多数非模板库以此方式发布)。
对于纯以头文件方式实现的 library,可以在你的程序的每个 .cpp 文件的第一行包含重载 ::operator new() 的头文件,这样程序里用到的其他 C++ library 也会转而使用你的 ::operator new() 来分配内存。
对于以库文件方式实现的 library,这么做并不能让其受惠,因为 library 的源文件已经编译成了二进制代码,它不会调用你新重载的 ::operator new。
替换 malloc()
直接从 malloc 层面入手,通过 LD_PRELOAD 来加载一个 .so,其中有 malloc/free 的替代实现(drop-in replacement),这样同时能为 C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new()。
对于检测内存错误,可以用 valgrind、dmalloc、efence 来达到相同的目的。
对于统计内存使用情况,替换 malloc 同样能够得到足够的信息,因为可以用 backtrace() 函数来获得调用栈,这比 new(FILE, __LINE) 的信息更丰富。
为单独的 class 重载 ::operator new() 有问题吗?
如果一个 class Node 需要重载 member ::operator new(),说明它用到了特殊的内存分配策略,常见情况是使用了内存池或对象池。具体地说,用 factory 来创建对象,比如 static Node* Node::createNode() 或者 static shared_ptr< Node > Node::createNode()。
有必要自行定制内存分配器吗?
如果写一个简单的只能分配固定大小的 allocator,确实很容易做到比系统的 malloc 更快,因为每次分配操作就是移动一下指针。
三、带符号整数的除法与余数
1 | const char* convert(char buf[], int value) |
可以作为 itoa() 的参考实现。《C Traps and Pitfalls》讲到,C 语言中的整数除法(/)和取模(%)运算在操作数为负的时候,结果是 implementation-defined。也就是说,如果 m、d 都是整数,
1 | int q = m / d; |
C 语言只保证 m = q × d + r。如果 m、d 当中有负数,那么 q 和 r 的正负号是由实现决定的。比如(-13)/ 4 = (-3)或(-13)/ 4 = (-4)都是合法的。
四、在单元测试中 mock 系统调用
在某些情况下,单元测试是很有必要的,在测试 failure 场景的时候尤其重要。
- 在开发存储系统时,模拟 read()/write() 返回 EIO 错误(有可能是磁盘写满了,也有可能是磁盘出现了坏道读不出数据)
- 在开发网络库的时候,模拟 write() 返回 EPIPE 错误(对方意外断开连接)
- 在开发网络库的时候,模拟自连接(self-connection),网络库应该用 getsockname() 和 getpeername() 判断是否是自连接,然后断开之
- 在开发网络库的时候,模拟本地 ephemeral port 耗尽,connect() 返回 EAGAIN 临时错误
- 让 gethostbyname() 返回我们预设的值,防止单元测试给公司的 DNS Server 带来太大压力
4.1、系统函数的依赖注入
1、采用传统的面向对象的手法,借助运行期的迟绑定实现注入与替换。自己写一个 System interface,把程序里用到的 open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname 等等函数统统用虚函数封装一层。在代码里不要直接调用 open(),而是调用 System::instance.open()。这样代码主动把控制权交给了 System interface。在写单元测试的时候,把这个 singleton instance 替换为 mock object,这样就能模拟各种 error code。
2、采用编译期或链接期的迟绑定。可以在写一个 system namespace 头文件,在其中声明 read() 和 write() 等普通函数,然后在 .cc 文件里转发给对应系统的系统函数 ::read() 和 ::write() 等。
1 | // .h |
有了这一层间接性,就可以在编写单元测试的时候链接我们的 stub 实现,以达到替换实现的目的
1 | int sockets::connect(int sockfd, const struct sockaddr_in& addr) |
一个 C++ 程序只能有一个 main() 入口,所以要先把程序做成 library,再用单元测试代码链接这个 library。假设有一个 mynetcat 程序,为了编写 C++ 单元测试,可以把它拆成两部分,即 library 和 main(),源文件分别是 mynetcat.cc 和 main.cc。
在编译普通程序的时候:
1 | g++ main.cc mynetcat.cc SocketsOps.cc -o mynetcat |
在编译单元测试时这么写:
1 | g++ test.cc mynetcat.cc MockSocketsOps.cc -o test |
namespace 的好处在于它不是封闭的,可以随时打开往里添加新的函数,而不用改动原来的头文件。这也是以 non-member non-friend 函数为接口的优点。
4.2、链接期垫片(link seam)
要仿冒 connect() 函数,可以在单元测试程序里实现一个自己的 connect() 函数,它遮盖了同名的系统函数。在链接的时候,linker 会优先采用我们自己定义的函数。(这对动态链接是城里的;如果是静态链接,会报 multiple definition 错误。好在绝大数情况下 libc 是动态链接的。)
1 | typedef int (*connect_func_t)(int sockfd, |
程序真的要调用 connect(),为了防止出现无限递归的情况,用 dlsym(RTDL_NEXT, “connect”) 获得 connect() 系统函数的真实地址,然后通过函数指针 connect_func 来调用它。
Link seam 同样适用于第三方 C++ 库
1 | class File : boost::noncopyable |
这个 class 没有适用虚函数,无法通过 sub-classing 的方法来实现 mock object。如果需要为用到 File class 的程序编写单元测试,我们可以自己定义其成员函数的实现,这样可以注入任何我们想要的结果。
1 | int File::readn(void* data, int len) |
这种做法对动态库是可行的,但对于静态库则会报错。
五、慎用匿名 namespace
其只要目的是让该 namespace 中的成员(变量或函数)具有独一无二的全局名称,避免名字碰撞(name collisions)。
5.1、C 语言的 static 关键字的两种用法
- 用于函数内部修饰变量,即函数内的静态变量。这种变量的生存期长于该函数,使得函数具有一定的状态。使用静态变量的函数一般是不可重入的,也不是线程安全的,比如 strtok()。
- 用在文件级别(函数体之外),修饰变量或函数,表示该变量或函数只在本文件可见,其他文件看不到、也访问不到该变量或函数。专业的说法叫“具有 internal linkage”(简言之:不暴露给别的 translation unit)。
5.2、C++ 语言的 static 关键字的四种用法
- 用于修饰 class 的数据成员,即所谓静态成员。这种数据成员的生存期大于 class 的对象(实体/instance)。静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份吗,因此也分别叫做 class variable 和 instance variable。
- 用于修饰 class 的成员函数,即所谓静态成员函数。这种成员函数只能访问 class viriable 和其他静态成员函数,不能访问 instance variable 或 instance method。
这几种用法可以组合,比如 C++ 的成员函数(无论 static 还是 instance)都可以有其局部的静态变量。对于 class template 和 function template,其中的 static 对象的真正个数跟 template instantiate(模板具现化)有关。
5.3、匿名 namespace 的不利之处
- 匿名 namespace 中的函数是匿名的,那么在确实需要引用它的时候就比较麻烦。比如在调试的时候不便给其中的函数设断点,又比如 profiler 的输出结果也不容易判别到底是哪个文件中的 calculate() 函数需要优化。
- 使用某些版本的 g++ 时,同一个文件每次编译出来的二进制文件会变化。比如说拿到一个会发生 core dump 的二进制可执行文件,无法确定它是由哪个 revision 的代码编译出来的。毕竟编译结果不可复现,具有一定的随机性。另外这也可能让某些 build tool 失灵,如果该工具用到了编译出来的二进制文件的 MD5 的话。
六、采用有利于版本管理的代码格式
6.1、对 diff 友好的代码格式
- 多行注释也用 //,不用 /* … */
- 局部变量与成员变量的定义
一行代码只定义一个变量,同样的道理适用于 enum 成员的定义、数组的初始化列表等。 - 函数声明中的参数
如果函数的参数大于3个,那么在逗号后面换行,这样每个参数占一行,便于 diff。1
2
3
4
5
6
7class TcpClient : boost::noncopyable
{
public:
TcpClient(EventLoop* loop,
const InetAddress& serverAddr,
const string& name);
} - 函数调用时的参数
在函数调用的时候,如果参数大于3个,那么把实参分行写。1
2
3
4
5
6
7
8Timestamp EpollPoller::poll(int timeoutMs, ChannelList* activeChannels)
{
int numEvents = ::epoll_wait(epollfd_,
&*events_.begin(),
static_cast<int>(events_.size()),
timeoutMs);
Timestamp now(Timestamp::now());
} - class 初始化列表的写法
class 初始化列表(initializer list)也遵循一行一个的原则。 - 与 namespace 有关的缩进
Google 的 C++ 编程规范明确指出,namespace 不增加缩进。方便 diff -p 把函数名显示在每个 diff chunk 的头上。diff 原本是为 C 语言设计的,C 语言没有 namespace 缩进一说,所以它默认会找到顶格写的函数作为一个 diff chunk 的名字。如果函数名前面有空格,它就不认得了。1
2
3
4
5
6
7
8
9namespace muduo
{
// class 从第一列开始写,不缩进
class Timestamp : public muduo::copyable
{
// ...
};
} - public 与 private
C++ diff 无法看出把一个函数从 public 区移到 private 区。 - 避免使用版本控制软件的 keyword substitution 功能
这么做是为了避免 diff 噪声。
6.2、对 grep 友好的代码风格
- 操作符重载
operator overloading 应仅限于和 STL algorithm/container 配合时使用,比如 std::transform() 和 map< Key, Value >,其他情况都用具名函数为宜。
又比如,Google Protocol Buffers 的回调是 Closure class,它的接口用的是 virtual function Run() 而不是 virtual operator()()。 - static_cast 与 C-style cast
为什么 C++ 要引入 static_cast 之类的转型操作符,原因之一就是像 (int*)pBuffer 这样的表达式基本上没办法用 grep 判断出它是个强制类型转换,写不出一个刚好只匹配类型转换的正则表达式。
七、再探 std::string
std::string 主要有三类实现方式
- 无特殊处理(eager copy),采用类似 std::vector 的数据结构。对象的大小是3个指针,在 32-bit 中是12字节,在 64-bit 中是24字节。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// class invariants:
// (1) [start, finish) is a valid range.
// (2) Each iterator in [start, finish) points to a valid object of type value_type.
// (3) *finish is a valid object of type value_type; in particular, it is value_type().
// (4) [finish + 1, end_of_storage) is a valid range.
// (5) Each iterator in [finish + 1, end_of_storage) points to uninitialized memory.
// Note one important consequence: a string of length n must manage a block of memory
// whose size is at least n + 1.
class string
{
public:
const_pointer data() const { return start; }
iterator begin() { return start; }
iterator end() { return finish; }
size_type size() const { return finish - start; }
size_type capacity() const { return end_of_storage - start; }
private:
char* start;
char* finish;
char* end_of_storage;
};
Eager copy string 的另一种实现方式是把后两个成员变量替换成整数,表示字符串的长度和容量。1
2
3
4
5
6
7
8
9
10
11
12
13
14class string
{
public:
const_pointer data() const { return start; }
iterator begin() { return start; }
iterator end() { return start + size_; }
size_type size() const { return size_; }
size_type capacity() const { return capacity_; }
private:
char* start;
size_t size_;
size_t capacity_;
}; - Copy-on-Write(COW)。g++ 的 std::string 一直采用这种方式实现。
string 对象里只放一个指针。1
2
3
4
5
6
7
8
9
10
11class cow_string // libstdc++-v3
{
struct Rep
{
size_t size;
size_t capacity;
size_t refcount;
char* data[1]; // variable length
};
char* start;
}; - 短字符串优化(SSO),利用 string 对象本身的空间来存储短字符串。Visual C++ 用的是这种实现方式。
无论哪种实现方式都要保存三个数据库:1、字符串本身(char[]),2、字符串的长度(size),3、字符串的容量(capacity)。
string 对象比前面两个都大,因为有本地缓冲区(local buffer)。1
2
3
4
5
6
7
8
9
10
11class sso_string
{
char* start;
size_t size;
static const int kLocalSize = 15;
union
{
char buffer[kLocalSize+1];
size_t capacity;
}data;
}
八、用 STL algorithm 做算法题
C++ STL 的 algorithm 配合自定义的 functor(仿函数、函数对象)可以用来解决某些算法题。
8.1、用 next_permutation() 生成排列组合
生成 N 个不同元素的全排列
把元素从小到大放好(即字典序最小的排列),然后反复调用 next_permutation()。
1 | int main() |
生成从 N 个元素中取出 M 个的所有组合
1 | int main() |
8.2、用 unique() 去除连续重复空白
std::unique() 的作用是去除相邻的重复元素。注意所有针对区间的 STL algorithm 都只能调换区间内元素的顺序,不能真正删除容器内的元素。
1 | struct AreBothSpaces |
8.3、用 {make, push, pop}_heap() 实现多路归并
题目 用一台 4GiB 内存的机器对磁盘上的单个 100 GB 文件排序
标准思路是先分块排序,然后多路归并并输出文件。多路归并用 heap 排序实现,比方说要归并已经按从小到大的顺序排好序的32个文件,可以构造一个32元素的 min heap,每个元素是 std::pair< Recode, FILE* >。然后每次取出堆顶的元素,将其 Record 写入输出文件;如果 FILE* 还可读,就读入一条 Record,再向 heap 中添加 std::pair< Recode, FILE* >。这样当 heap 为空的时候,多路归并就完成了。这个过程中 heap 的大小通常会慢慢变小,因为有可能某个输入文件已经全部读完了。
1 | typedef int Record; |
8.4、用 partition() 实现重排数组,让奇数位于偶数前面
std::partition() 的作用是把符合条件的元素放到区间首部,不符合条件的元素放到区间后部。
1 | bool isOdd(int x) |
如果要求保持原来的数字先后顺序不变,可以用 std::stable_partition()。
8.5、用 lower_bound() 查找 IP 地址所属的城市
1 | struct IPrange |
8.6、STL algorithm 算法分类
- 容易,手写一遍的难度跟 strlen() 和 strcpy() 差不多。这类算法基本上是遍历一遍输入区间,对每个元素做些判断或操作,一个 for 循环就可以解决问题。一半左右的 STL algorithm 属于此类,例如 for_each()、transform()、accmulate() 等等。
- 较难,要写出正确的实现要考虑清楚各种边界条件。例如 merge()、unique()、remove()、random_shuffle()、lower_bound()、partition() 等等。
- 难,例如 sort()、nth_element()、next_permutation()、inplace_merge() 等等。
参考文章:
Linux多线程服务端编程
- 本文标题:C++经验谈
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/89b90f5f.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!