这是bert hubert的系列文章,旨在帮助c代码人快速了解c++实用的新特性。原文链接:https://berthub.eu/
欢迎回来!在第1部分中,我讨论了std::string和std::vector如何与C协同工作,包括与C标准库的qsort调用。我们还发现C++ std::sort比C qsort快40%,因为C++能够内联比较函数。
在这一部分,我们将继续讨论您可以用来点缀代码的更多C++功能,而无需立即使用《C++编程语言》的所有1400页。
在GitHub上可以找到这里讨论的各种代码示例。
如果您有任何想要讨论的喜欢的事物或问题,请通过@bert_hu_bert或[email protected]联系我
命名空间允许具有相同名称的事物并存。这对我们立即相关,因为C++定义了很多函数和类,这些函数和类可能与您已经在C中使用的名称冲突。正因为如此,C++库位于std::命名空间中,这使得编译C代码成为C++变得更加容易。
为了省去大量输入,可以使用using namespace std
导入整个std::
命名空间,或者选择单个名称:using std::thread
。
C++本身确实有一些关键字,如this
、class
、throw
、catch
和reinterpret_cast
,这些关键字可能与现有的C代码发生冲突。
C++的早期名称是“c with class
”,它由将这种新的C++转换成纯C的转换器组成。有趣的是,这个转换器本身就是用“c with class
”编写的。
大多数高级C项目已经使用与C++几乎完全相同的类。在其最简单的形式中,一个类无非就是一个具有某些调用约定的结构体。(继承和虚函数使场景更复杂,这些可选技术将在第3部分中讨论)。
典型的现代C代码会定义一个描述某事物的结构体,然后有一堆函数接受指向该结构体的指针作为第一个参数:
struct Circle {
int x, y;
int size;
Canvas* canvas;
...
};
void setCanvas(Circle* circle, Canvas* canvas);
void positionCircle(Circle* circle, int x, int y);
void paintCircle(Circle* circle);
事实上,许多C项目甚至会使这些结构体的一部分不透明,表示有内部成员API用户不应该看到。这是通过在.h文件中向前声明一个结构体但从不定义它来完成的。sqlite3句柄就是这种技术的一个很好的例子。
一个C++类的布局就像上面的结构体一样,事实上,如果它包含方法(成员函数),这些方法在内部以完全相同的方式被调用:
class Circle {
public:
Circle(Canvas* canvas); // "构造函数"
void position(int x, int y);
void paint();
private:
int d_x, d_y;
int d_size;
Canvas* d_canvas;
};
void Circle::paint() {
d_canvas->drawCircle(d_x, d_y, d_size);
}
如果我们“水下看”,Circle::position(1, 2)
实际上被调用为Circle::position(Circle* this, int x, int y)
,此二者无异,而且也没有更多的魔法(或开销)。此外,Circle::paint
和Circle::position
函数都有d_x
、d_y
、d_size
和d_canvas
作用域。
唯一的区别是这些“私有成员变量”不能从外部访问。例如,这可能有助于当x
的任何更改都需要与Canvas
协调时,我们不希望用户在我们不知情的情况下更改x
。如前所述,许多C项目通过技巧实现相同的不透明性 - 这只是一种更轻松的方法。
到目前为止,一个类不过是语法糖和一些作用域规则。然而…
大多数现代语言采用垃圾回收,因为手动跟踪内存显然太困难。这导致周期性的垃圾回收运行,有可能“停止世界”。尽管技术在不断改进,垃圾回收在多核环境下仍然是一个令人头疼的问题。
虽然C和C++没有垃圾回收,但在各种(错误)条件下跟踪每一次内存分配仍然异常困难。C++提供了一些复杂的方法来帮助你,这些方法建立在被称为构造函数和析构函数的基础上。
SmartFP是一个例子,在后续章节我们会加强它,使其变得实用和安全:
struct SmartFP
{
SmartFP(const char* fname, const char* mode)
{
d_fp = fopen(fname, mode);
}
~SmartFP()
{
if(d_fp)
fclose(d_fp);
}
FILE* d_fp;
};
注意:除了默认元素属性为“public
”外,class
和struct
并无二样。 SmartFP
用法如下:
void func()
{
SmartFP fp("/etc/passwd", "r");
if(!fp.d_fp)
// do error things
char line[512];
while(fgets(line, sizeof(line), fp.d_fp)) {
// do things with line
}
// note, no fclose
}
正如编写的这样,对fopen()的实际调用发生在SmartFP对象实例化时。这会调用构造函数,构造函数名称与类本身相同:SmartFP
。
然后,我们可以像往常一样使用类中存储的FILE*
。最后,当fp
离开作用域时,它的析构函数SmartFP::~SmartFP()
会被调用,如果构造函数中d_fp
成功打开,它会为我们关闭fclose()
。
通过这种方式,代码有两个巨大优势:
2)可以精确控制FILE指针的关闭时机。
垃圾回收语言也能保证不泄漏,但是很难精确控制资源释放时机。
使用构造函数和析构函数的RAII技术可以安全地管理资源,这在C++中被广泛使用。大型C++项目通常只在构造和析构函数中调用new
/delete
等,或者根本不调用,全部依赖RAII的自动资源管理。
内存泄漏是每个项目的祸根。即使有垃圾回收,也有可能在仅显示聊天消息的单个窗口中占用数吉字节的内存。
C++提供了许多所谓的智能指针,每个都有其自己的优缺点,可以提供帮助。最“照我意思行事”的智能指针是std::shared_ptr,其最基本的形式可以像这样使用:
void func(Canvas* canvas)
{
std::shared_ptr ptr(new Circle(canvas));
// or better:
auto ptr = std::make_shared(canvas)
}
最初的形式展示了C++进行malloc
的方式,在这种情况下,为Circle
实例分配内存,并用canvas
参数构造它。如前所述,大多数现代C++项目很少使用“赤裸new
”语句,而是大多将它们封装在负责(解)分配的基础结构中。
第二种方式不仅输入更少,而且效率也更高。
然而,std::shared_ptr
还有更多技巧:
// make a vector of shared pointers to Circle instances
std::vector > circles;
void func(Canvas* canvas)
{
auto ptr = std::make_shared(canvas)
circles.push_back(ptr);
ptr->draw();
}
“这首先定义了一个std::shared_ptr
的向量,然后创建这样一个shared_ptr
并将其存储在circles
向量中。当func
返回时,ptr
超出范围,但由于它的副本在circles
向量中,所以Circle
对象仍然存活。因此,std::shared_ptr
是一个引用计数智能指针。
std::shared_ptr
还有另一个漂亮的特性,如下所示:”
void func()
{
FILE *fp = fopen("/etc/passwd", "r");
if(!fp)
; // do error things
std::shared_ptr ptr(fp, fclose);
char buf[1024];
fread(buf, sizeof(buf), 1, ptr.get());
}
在这里,我们使用一个名为fclose
的自定义删除器创建一个shared_ptr
。这意味着如果需要,ptr
知道如何清理自己,并且通过一行代码就创建了一个引用计数的FILE
句柄。
通过这个,我们现在可以看到,我们之前定义的SmartFP
使用并不很安全。有可能对它进行复制,一旦那个副本超出作用域,它也会关闭同一个FILE\*
。std::shared_ptr
节省了我们考虑这些事情的精力。
std::shared_ptr
的缺点是它使用内存进行实际的引用计数,在多线程操作中也必须进行保护。它还必须存储一个可选的自定义删除器。
C++还提供了其他智能指针,其中最相关的就是std::unique_ptr
。
通常,我们实际上不需要实际的引用计数,只需要“如果超出作用域就清理”。这就是std::unique_ptr
提供的,几乎没有开销。
还有一些设施可以“移动”一个std::unique_ptr
到存储中,这样它就可以保持在作用域内。我们稍后会回到这一点。
每次我用C或旧版C++中的pthread_create
创建一个线程时,我都会感到不好。必须通过一个void
指针压缩所有启动线程的数据,感觉很愚蠢且危险。
C++在本地线程系统之上提供了一个强大的层,以使所有这些变得更容易、更安全。另外,它还提供了从线程轻松获取数据的方法。
一个小示例:
double factorial(unsigned int limit)
{
double ret = 1;
for(unsigned int n = 1 ; n <= limit ; ++n)
ret *= n;
return ret;
}
int main()
{
auto future1 = std::async(factorial, 19);
auto future2 = std::async(factorial, 12);
double result = future1.get() + future2.get();
std::cout<<"Result is: " << result << std::endl;
}
std::thread t(factorial, 19);
t.join(); // or t.detach()
像C11一样,C++提供了原子操作。这些操作就像定义std::atomic
一样简单。对packetcounter
的操作是原子的,如果需要特定模式来构建无锁数据结构,有很多种方法可以查询或更新packetcounter
。
注意,与C一样,声明一个从多个线程使用的计数器为volatile
没有任何用处。需要完整的原子操作,或者显式锁。
与跟踪内存分配一样,确保在所有代码路径上释放锁都是很难的。和往常一样,使用RAII解决这一问题:
std::mutex g_pages_mutex;
std::map g_pages;
void func()
{
std::lock_guard guard(g_pages_mutex);
g_pages[url] = result;
}
The guard object above will keep g_pages_mutex locked for a long as needed, but will
always release it when func() is done, through an error or not.
上述的guard
对象将保持g_pages_mutex
锁住,直到func
函数结束。
老实说,错误处理在任何语言中都是一个处理得不好的问题。我们可以在代码中设置大量检查,在每个检查点我都在想“如果失败,程序实际上应该做什么”。选择很少是好的——忽略、提示用户、重启程序,或者记录一条消息,希望有人读到它。
C++提供了异常,无论如何,与检查每个返回代码相比都有一些优势。异常的好处是,与返回代码不同,它默认不会被忽略。首先让我们更新SmartFP
以抛出异常:
std::string stringerror()
{
return strerror(errno);
}
struct SmartFP
{
SmartFP(const char* fname, const char* mode)
{
d_fp = fopen(fname, mode);
if(!d_fp)
{
throw std::runtime_error("Can't open file: " + stringerror();
}
~SmartFP()
{
fclose(d_fp);
}
FILE* d_fp;
};
如果我们没有创建并使用SmartFP
,而且没有抛出任何错误,那么我们就知道这样使用没错。如果抛出了错误,我们就可以捕获遗产:
void func2()
{
SmartFP fp("nosuchfile", "r");
char line[512];
while(fgets(line, sizeof(line), fp.d_fp)) {
// do things with line
}
// note, no fclose
}
void func()
{
func2();
}
int main()
try {
func();
}
catch(std::exception& e) {
std::cerr<< "Fatal error: " << e.what() << std::endl;
}
这展示了一个异常从SmartFP::SmartFP
抛出,然后“穿过”func2()
和func()
被main()
捕获。好处是关于回溯是错误总是会被注意到,不像一个简单的返回代码可能会被忽略。缺点是异常可能被“捕获”离它被抛出很远的地方,这可能带来意外的惊喜。但这通常确实带来了良好的错误日志记录。
结合RAII
,异常是一种非常强大的技术,可以安全获取资源,并处理错误。
能抛出异常的代码比不能抛出异常的代码略慢,但在profiles
中几乎看不出来。然而,实际抛出异常开销相当大,所以只在错误条件下使用它。
大多数调试器可以在抛出异常时中断,这是一种强大的调试技术。在gdb
中,这通过catch throw
完成。
如前所述,没有错误处理技术是完美的。std::expected
工作或boost::expected
看起来很有前途,它创建的函数既有返回代码也可以抛出异常,如果你不查看它们的话。
在“面向C程序员的C++”第2部分中,我们展示了类实际上已经在C中广泛使用的概念,只是C++使其更容易。另外,C++类(和结构)可以有构造函数和析构函数,这对于确保在需要时获取和释放资源非常有用。
基于这些基本要素,C++提供了不同智能水平和开销的智能指针,满足大多数需求。
此外,C++为线程、原子操作和锁提供了很好的支持。最后,异常是一种强大的方式来处理错误。