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

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

Modern C++ for C 程序员: 第3部分

欢迎回来!在第2部分中,我讨论了基本类、线程、原子操作、智能指针、资源获取和(非常简短地)命名空间。

在这一部分,我们将继续探讨 C++ 的其他特性,您可以使用这些特性来增加代码的“逐行”调味品,而不需要立即使用《C++ 编程语言》的所有1400页。

这里讨论的各种代码示例可在 GitHub 上找到。

如果您有任何想讨论的喜欢的事物或提出问题,请随时通过 @bert_hu_bertbert@hubertnet.nl 与我联系。

继承和多态性

这些功能有时有用,但绝不是必要的。完全有可能编写不使用任何继承的 C++。换句话说,没有压力或需要“全部面向对象”。

但它确实有其用途。例如,事件处理库可能必须处理各种事件,所有这些事件都必须通过单个 API 传递。它的工作原理如下:

class Event 
{
  public:
  std::string getType();

  struct timeval getTime();

  virtual std::string getDescription()=0;

  private:
  std::string m_type;

  struct timeval m_time;
};

这是名为 Event 的基类。它有三种方法,两种是普通成员函数:getType()getTime()。这些从每个 Event 共享的两个成员变量返回数据。

第三个 getDescription() 是虚拟的,这意味着它在派生类中是不同的。此外,我们将该函数设置为零,这意味着每个派生类都将定义此函数。换句话说,您无法执行此操作:

Event e; // 将在'pure virtual getDescription'上出错

要使实际的 Event 可以工作,我们这样做:

class PortScanEvent : public Event 
{
  public:
  virtual std::string getDescription() override
  {
    return "Port scan from "+m_fromIP;  
  }
  private:
  std::string m_fromIP;
};

这定义了一个继承自 Event 的派生类,Event 是它的“基类”。请注意,我们如何在此定义 getDescription,并且它被标记为 override,这意味着编译器将在我们没有实际覆盖基类方法的情况下输出错误。

假设我们还创建了一个 ICMPEvent,我们现在可以写入:

PortScanEvent pse;
cout << pse.getType() << endl; // "Portscan"  

ICMPEvent ice;
cout << ice.getType() << endl; // "ICMP"

这完全是常规的—我们用的对象一部分定义在了其基类中。

然而,我们也可以执行:

Event* e = &ice;
cout << e->getDescription() << endl; // "ICMP of type 7"
e = &pse;
cout << e->getDescription() << endl; // "Portscan from 1.2.3.4"

这定义一个 Event 类型的指针变量,其中首先存储了一个指向 ICMPEvent 的指针。
它继续充当 ICMPEvent,即使存储在 Event 类型的指针中。
接下来的两行演示了这对于 PortScanEvent 也是如此。

Event 包含了某种元数据 —可以让我们在运行时知道它真正持有的类型— 如果其他的调用需要知道实际类,则会参考此元数据。这代表了开销,但与 C 项目最终模拟类时生成的开销相同。

有趣的是,在上述情况下,编译器有时能够‘脱虚拟化’调用,因为从控制流中,它们在编译时知道 Event 的实际类型将是什么 — 并非通常在 C 中模拟的类实现的优化。

最后,如果您需要,可以按如下方式找出 Event 的实际类型:

auto ptr = dynamic_cast<PortScanEvent*>(e);
if(ptr) {
  cout << "This is a PortScanEvent" << endl;  
}

如果您误解了元数据类型,ptr 将为 0(或 modern C++ 术语中的 nullptr)。

我个人很少在项目中使用运行时多态性,如果使用,几乎完全是用于需要接收/响应不同类型记录或事件的 API。每当您有需要存储在单个数据结构中的不同“事物”集合时,它都非常有用

值得注意的一点是,一旦您发现自己编写的函数以结构的类型开头 switch 语句,您可能最好使用实际的 C++ 继承。

关于引用的简短说明

void f(int& x)
{
	x=0; 
}

...

int i = 0;
int& j = i;
j = 2;
cout << i << endl; // 打印 2
f(i); // i 和 j 现在都为 0

到目前为止,我一直忽略描述引用,引用出现在本系列的第 1 部分和第 2 部分的两个示例中。从技术上讲,引用不过是指针。没有开销。它们非常相似,以至于人们可能会质疑 C++ 为什么要提供这种替代语法。指针已经提供了按引用传递语义。

这里有一些更精细的要点,但引用确实节省了一些输入。函数现在可以“通过引用”返回事物,而不强制您在每次使用时添加 * 或 -> 例如。

这使容器能够实现:v[12]=4 例如,其底层是 value_type& operator[](size_t offset)。如果这返回一个 value_type,我们要为每个地方键入(v[12])=12

模板

如第 1 部分中所述,C++ 的设计遵循“零开销”原则,其第二部分规定“[不]应添加编译器生成的代码不会比程序员在不使用该功能的情况下创建的代码好的任何功能”。这是一个大胆的声明。

被称为“面向对象”的大多数编程语言都使所有对象从 magic Object 基类派生(或继承)。这意味着在这样的语言中编写容器意味着编写托管 Object 实例的数据结构。如果我们在其中存储一个 ICMPEvent,我们将其存储为一个 Object。

注释:面相对象,因此管理、操作对象中的数据可能需要将该对象同时实例化一次;

使用这种技术的问题是,对于 C++,它违反了“零开销”原则。在 C 中存储 10 亿个 32 位数字需要 4GB 内存。存储 10 亿个 Object 实例将使用不少于 16GB - 很可能会更多。

为了遵守其“零开销”的承诺,C++ 实现了“模板”。模板就像超级宏,功能强大,也必须说,复杂。但是它们是值得的,因为它们支持库提供通用容器,这些容器与您自己编写的一样好,很可能会更好 - 并且留下了创建更好版本的可能性。

简短的示例:

template<typename T>
struct Vector  
{
  void push_back(const T& t) 
  {
    if(size + 1 > capacity) {
      if(capacity == 0)
        capacity = 1;
      auto newcontents = new T[capacity *= 2];
      for(size_t a = 0 ; a < size ; ++a)
        newcontents[a]=contents[a];  
      delete[] contents;
      contents=newcontents;
    }
    contents[size++] = t;
  }

  T& operator[](size_t pos)
  {
    return contents[pos];  
  }

  ~Vector()
  {
    delete[] contents;
  }

  size_t size{0}, capacity{0};
  T* contents{nullptr};
};

这实现了任意类型的简单自动增长向量。它的使用如下:

Vector<uint32_t> v;
for(unsigned int n = 0 ; n < 1000000000; ++n) {
  v.push_back(n);
}

当编译器遇到第一行时,它会用 uint32_t 替换 T 来实例化 Vector。这会产生与您手动编写的完全相同的代码。没有开销。

类似地,函数也可以模板化,例如:

// 在Vector内
template<typename C> 
bool isSorted(C pred)
{
  if(!size)
    return true;

  for(size_t n=0; n < size - 1; ++n)
    if(!pred(contents[n], contents[n+1]))
      return false;

  return true;
}
}  

并有一个样本使用:

struct User  
{
  std::string name;
  int uid;
};

Vector<User> users;

// 填充用户
if(users.isSorted([](const auto& a, const auto& b) {
  return a.uid < b.uid;}) 
   {
     // 做事情
   }

这段代码会编译成尽可能高效,就像您自己编写 a.uid < b.uid 一样。顺便说一下,因为模板非常通用,您也可以传递函数指针或整个对象给 isSorted,只要编译器可以调用它就可以。顺便说一下,正如第 1 部分所示,传递“谓词”这样可以使 std::sort 比 C qsort 更快。

根据这些模板,C++ 提供了一系列强大的容器来存储数据。每个容器都有一个 API,但也有性能(缩放)保证。这反过来确保了实现者必须使用最先进的算法——而且他们确实在使用。

注意:这也意味着上述任何示例代码都不应该投入生产—— std::vector 和相关的 std::is_sorted 已经存在,并且做得更好。

您可能会发现自己使用许多这些模板化容器和相关算法,但很少自己编写任何模板化函数。这在两个方面都是相当好的消息——首先,编写模板化代码比您认为的更难,它有一些令人惊讶的语法不便。但其次,您想要编写模板的几乎所有内容都已经编写好了,所以很少有需要。

一个工作示例

我们崇高的“C 语言编程”包含示例代码来统计 C 程序中 C 关键字的使用情况。这本好书完全有理由从头开始创建相关的数据结构。

然而,在我们的 C++ 环境中,我们知道我们的旅程从非常强大的数据结构开始,所以让我们不仅统计代码中的单词,而且索引所有内容。下面是一个 100 行的示例,它在 10 秒内索引整个 Linux 内核源代码(692MB),并执行即时查找。

首先,让我们读取要索引的文件:

int main(int argc, char** argv)
{
  vector<string> filenames; 
  ifstream ifs(argv[1]);
  std::string line;
  while(getline(ifs, line))
    filenames.push_back(line);
  cout<<"Have "<<filenames.size()<<" files"<<endl;
}

我们将要索引的文件名文件读入 std::vector。如前所述,C++ iostreams 并非强制性的,您可以继续使用 C stdio,但在这种情况下,iostreams 是读取任意长度文本行的便捷方式。

接下来,我们依次读取和索引每个文件:

unsigned int fileno=0;
std::string word;
for(const aut	o& fname : filenames) { // "range-based for"
  size_t offset=0;
  SmartFP sfp(fname.c_str(), "r");
  while(getWord(sfp.d_fp, word, offset)) {
    allWords[word].push_back({fileno, offset});
  }
  ++fileno;
}

这种特殊形式的 for 循环遍历 std::vector filenames 的内容。这种语法适用于所有标准 C++ 容器,并且如果您遵循一些简单的规则,也将适用于您的容器。

在下一行中,我们使用 part 2 中定义的 SmartFPSmartFP 在内部携带 C FILE*,这意味着我们获得了 C stdio 的原始速度。

getWord 可以在我们的 sample code GitHub 中找到,唯一需要注意的特殊之处是 std::string word 作为引用传递。这意味着我们可以继续重用相同的字符串实例,这可以节省大量 malloc 流量。它还通过引用传递 offset,并且此 offset 将更新为在文件中找到此单词的偏移量。

allWords 行是发生动作的地方。allWords 定义如下:

struct Location
{
  unsigned int fileno;  
  size_t offset;
};

std::unordered_map<string, vector<Location>> allWords;

这将创建一个无序的关联容器,以字符串为键。每个条目都包含一个 Location 对象向量,每个对象都表示找到此单词的一个地方。

在内部,unordered 容器基于键的哈希,默认情况下,C++ 已经知道如何哈希大多数基本数据类型。与有序的 std::map 相比,无序变量的速度提高了一倍。

要实际查找某些内容,我们执行:

while(getline(cin, line)) {
  auto iter = allWords.find(line); 
  if(iter == allWords.end()) {
    cout<<"Word '"<<line<<"' was not found"<<endl;
    continue;
  }
  cout<<"Word '"<<line<<"' occurred "<<iter->second.size()<<" times: "<<endl;
  for(const auto& l : iter->second) {
    cout<<"\tFile "<<filenames[l.fileno]<<", offset "<<l.offset<<endl;
  }  
}

这引入了迭代器的概念。有时迭代器不过是一个指针,有时它更复杂,但它总是表示容器中的一个“位置”。有两个魔术般的地方,一个 begin(),一个 end()。要表示没有找到任何内容,将返回 end() 迭代器,我们在下一行中检查这一点。

如果我们找到了一些内容,我们使用迭代器语法打印我们找到的匹配项数:iter->second.size()。对于关联容器,迭代器有两部分,方便地称为 first 和 second。第一个具有该项的键,而另一个提供了在那里找到的值(或实际项目)的访问权限。

在最后三行中,我们再次使用基于范围的 for 语法来循环访问我们搜索的单词的所有 Location。

现在,为了校准,我们在仅使用默认功能的情况下花费了不到 50 行独特的代码,这有多好?

$ find ~/linux-4.13 -name "*.c" -o -name "*.h" > linux
$ /usr/bin/time ./windex linux < /dev/null  
Have 45000 files
...
45000/45000 /home/ahu/linux-4.13/tools/usb/ffs-test.c, 103302249 words, 607209844 bytes
Read 692542148 bytes  
Done indexing
9.62user 1.09system 0:10.73elapsed 99%CPU (0avgtext+0avgdata 2047712maxresident)

这代表约 65MB/s 的索引速度,覆盖 692MB 数据。查找是瞬时的。内存使用量为 2GB,大约是每个单词 20 字节,这包括单词本身的存储。

总结

C++ 提供了继承和多态类,这些有时很有用,肯定比手动编码的等价实现更好,但绝对不是强制使用的。

作为从 Object 派生所有内容的替代方法(就像一些语言所做的那样),C++ 提供了模板,它提供了适用于任意类型的泛型代码。这就是 std::sort 能够比 C 中的 qsort 更快的原因。

模板非常强大,是 C++ 标准容器数组如 std::mapstd::unordered_mapstd::vector 等的基础。使用基于范围的 for 循环和迭代器,可以查询和修改容器。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值