Modern C++ for C程序员 第2部分

文章目录

  • Modern C++ for C程序员 第2部分
    • 命名空间
    • 资源获取即初始化(RAII)
    • 智能指针
    • 线程,原子操作
    • 错误处理
  • 总结

这是bert hubert的系列文章,旨在帮助c代码人快速了解c++实用的新特性。原文链接:https://berthub.eu/

Modern C++ for C程序员 第2部分

欢迎回来!在第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++本身确实有一些关键字,如thisclassthrowcatchreinterpret_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::paintCircle::position函数都有d_xd_yd_sized_canvas作用域。

唯一的区别是这些“私有成员变量”不能从外部访问。例如,这可能有助于当x的任何更改都需要与Canvas协调时,我们不希望用户在我们不知情的情况下更改x。如前所述,许多C项目通过技巧实现相同的不透明性 - 这只是一种更轻松的方法。

到目前为止,一个类不过是语法糖和一些作用域规则。然而…

资源获取即初始化(RAII)

大多数现代语言采用垃圾回收,因为手动跟踪内存显然太困难。这导致周期性的垃圾回收运行,有可能“停止世界”。尽管技术在不断改进,垃圾回收在多核环境下仍然是一个令人头疼的问题。

虽然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”外,classstruct并无二样。 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()

通过这种方式,代码有两个巨大优势:

  1. FILE指针不会内存泄漏 ;

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的操作是原子的,如果需要特定模式来构建无锁数据结构,有很多种方法可以查询或更新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++为线程、原子操作和锁提供了很好的支持。最后,异常是一种强大的方式来处理错误。

你可能感兴趣的:(c++,c语言)