2016 年 4 月

第 31 卷,第 4 期

Visual C++ - Microsoft 将 C++ 包含在未来的发展计划中

作者 Kenny Kerr | 2016 年 4 月

Visual C++ 落下了落后于曲线的名声。如果您想获得最新和最棒的 C++ 功能,您应该只使用 Clang 或 GCC,否则就又会重提那个老生常谈的故事了。如果您愿意的话,我建议对现状、矩阵中的错误和干扰力量进行更改。事实上,Visual C++ 编译器的代码库已经极其陈旧,这使得 Microsoft C++ 团队很难快速地添加新功能 (goo.gl/PjSC7v)。虽然 Visual C++ 是很多有关 C++ 语言和标准库的新方案的“着地点”,但这一情况已经开始改变了。我将着重说明 Visual C++ Update 2 版本中我觉得人们非常感兴趣的新功能或改进的功能,并举例说明这个陈旧的编译器还是有活力的。

模块

一些 Microsoft 开发人员(特别是 Gabriel Dos Reis 和 Jonathan Caves)一直在研究将组件化支持直接添加到 C++ 语言的设计。次要目标是改善版本吞吐量,类似于预编译标头。他们已经针对 C++ 17 提议了这一设计(称之为用于 C++ 的模块系统),且新的 Visual C++ 编译器为 C++ 中的模块提供了概念证明和操作实现的起点。模块旨在使任何使用标准 C++ 的开发人员都能非常直观、自然地创建和使用。确保您已经安装了 Visual C++ Update 2,打开了开发人员命令提示符,并遵循我演示的方式进行操作。由于功能还处于实验阶段,因此它缺少任何的 IDE 支持,而最好的开始方式就是直接从命令提示符使用编译器。

想象一下,我要把现有的 C++ 库分发为模块,可能类似以下详细内容:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
inline void dog()
{
  printf("woof\n");
}
inline void cat()
{
  printf("meow\n");
}

我的内部库可能还会附带一个引人注目的示例应用:

C:\modules> type app.cpp
#include "animals.h"
int main()
{
  dog();
  cat();
}

来自 C++ 积极分子的压力让我羞于使用 printf。但我无法拒绝它无与伦比的性能,所以我决定把库转变为模块,以隐藏我更喜欢 printf,而不是其他 I/O 形式的事实。我可以从编写模块接口开始:

C:\modules> type animals.ixx
module animals;
#include "animals.h"

当然,我可以只定义模块接口文件中的 cat 和 dog 函数,但把它们包含在其中也无妨。模块声明会告知编译器,下面的内容是模块的一部分,但这并不意味着随后的声明都会被导出为模块界面的一部分。目前为止,该模块还未导出任何内容,除非 animals.h 中包括的 stdio.h 标头恰好自行导出了一些内容。我甚至可以通过在模块声明之前包括 stdio.h 预防这种情况。所以,如果这个模块接口确实不声明任何公共名称,我怎样才能导出内容供他人使用呢? 我需要使用导出关键字;它(和模块以及导入关键字)是我唯一需要考虑的 C++ 语言添加。它证明了这一新语言功能美好的简洁性。

首先,我可以导出 cat 和 dog 函数。这涉及到更新 animals.h 标头和使用导出说明符开始两项声明,如下所示:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
export inline void dog()
{
  printf("woof\n");
}
export inline void cat()
{
  printf("meow\n");
}

现在,我可以使用编译器的实验模块选项编译模块接口文件:

C:\modules> cl /c /experimental:module animals.ixx

请注意,我还包括了 /c 选项,以指示编译器仅进行编译,但不链接代码。在此阶段,使链接器尝试创建可执行文件毫无意义。模块选项指示编译器生成一个文件,其中包括说明接口的元数据和二进制形式的模块实现。此元数据并非计算机代码,而是 C++ 语言构造的二进制表示法。但是,它也不是源代码,这有利有弊,具体取决于您的看法。利在于,这应该会提高版本的吞吐量,因为导入模块的应用无需重新分析代码。另一方面,这也就意味着不一定会有源代码供传统工具(例如 Visual Studio 及其 IntelliSense 引擎)查看和分析了。这就意味着我们需要教会 Visual Studio 和其他工具如何在模块中挖掘和查看代码。好消息是,模块中的代码或元数据均以开放形式存储,我们只需更新工具就能处理。

继续进行,应用就可以直接导入模块(而不是库标头)了:

C:\modules> type app.cpp
import animals;
int main()
{
  dog();
  cat();
}

导入声明指示编译器查找匹配的模块接口文件。然后,它就能使用这些文件,和其他任何文件一起(包括应用中可能存在的文件)解析 dog 和 cat 函数了。幸好,animals 模块会导出一对 furry 函数,您可以使用相同的模块命令行选项对应用进行重新编译:

C:\modules> cl /experimental:module app.cpp animals.obj

请注意,这一次我允许编译器调用链接器,因为我其实并不想生成可执行文件。我们仍然需要实验模块选项,因为导入关键字尚未正式发布。接下来,在编译模块时,链接器还会要求生成对象文件。这就再次暗示了这样一个事实:包括模块元数据的新二进制形式其实并非“代码”,而仅仅是对导出的声明、函数、类、模板等的说明。当您真正想使用该模块构建应用时,您仍然需要对象文件允许链接器进行将代码汇编为可执行文件的工作。如果一切正常,那我现在也拥有了一个可以像其他任何文件一样运行的可执行文件,这一最终结果与使用纯标头库的原始应用无异。换句话说,模块并不是 DLL。

现在,我恰好在处理一个很大的库,而向每个声明添加导出的想法确完全没有吸引力。幸好,导出声明不仅仅是能导出函数。一个选项是用一对括号导出大量声明,如下所示:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
export
{
  inline void dog()
  {
    printf("woof\n");
  }
  inline void cat()
  {
    printf("meow\n");
  }
}

这不会引入新的范围,仅用于对任何包含的导出声明进行分组。当然,全世界没有哪一个有自尊心的 C++ 程序员会使用大量声明编写库。相反,我的 animals.h 标头很有可能会在命名空间中声明 dog 和 cat 函数,而作为整体的命名空间可以很轻易地导出:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
export namespace animals
{
  inline void dog()
  {
    printf("woof\n");
  }
  inline void cat()
  {
    printf("meow\n");
  }
}

从纯标头库迁移到模块的另一个微妙的好处是,应用无法再无意中依赖于 stdio.h,因为这不是模块接口的一部分。如果纯标头库包括了不适合应用直接使用的嵌套命名空间(包括实现详细信息)怎么办? 图 1 显示的是此类库的典型示例。

图 1 带有实现命名空间的纯标头库

C:\modules> type animals.h
#pragma once
#include <stdio.h>
namespace animals
{
  namespace impl
  {
    inline void print(char const * const message)
    {
      printf("%s\n", message);
    }
  }
  inline void dog()
  {
    impl::print("woof");
  }
  inline void cat()
  {
    impl::print("meow");
  }
}

此库的使用者深知,他不能依赖于实现命名空间上的任何内容。当然,编译器并不会阻止恶意开发人员的此类操作:

C:\modules> type app.cpp
#include "animals.h"
using namespace animals;
int main()
{
  dog();
  cat();
  impl::print("rats");
}

模块会有所帮助吗? 当然,但是请记住,模块是基于使功能尽可能小或尽可能简单这一理念设计的。所以,一旦导出声明,所有内容都会无条件导出:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
export namespace animals
{
  namespace impl
  {
    // Sadly, this is exported, as well
  }
  // This is exported
}

幸运的是,如图 2 所示,您可以重新排列代码,以便在保留库的命名空间结构时单独声明 animals::impl 命名空间。

图 2 保留库的命名空间结构

C:\modules> type animals.h
#pragma once
#include <stdio.h>
namespace animals
{
  namespace impl
  {
    // This is *not* exported -- yay!
  }
}
export namespace animals
{
  // This is exported
}

现在,我们只需要让 Visual C++ 实现嵌套的命名空间定义即可,有了大量嵌套的命名空间,它看上去更加美观,也更便于库对其进行管理:

C:\modules> type animals.h
#pragma once
#include <stdio.h>
namespace animals::impl
{
  // This is *not* exported -- yay
}
export namespace animals
{
  // This is exported
}

希望 Visual C++ Update 3 可以实现这一功能。祈祷吧! 就现在而言,animals.h 标头将破坏那些仅包括标头,并且可能是由尚未支持模块的编译器构建的现有应用。如果您需要在将其缓慢向模块过渡的同时支持现有库用户,您可以在过渡期间使用可怕的预处理器进行平滑处理。但这并不是理想的做法。很多更新的 C++ 语言功能的设计(包括模块)都是为了使无宏 C++ 编程越来越可信。在模块实际登陆 C++ 17 且开发人员可进行商业实现之前,我仍然可以使用一个小的预处理器技巧将 animals 库作为纯标头库和模块进行构建。在我的 animals.h 标头中,我可以按条件将 ANIMALS_­EXPORT 宏定义为无,如果它之前是一个模块,我可以使用它优先导出我要导出的任何命名空间(见图 3)。

图 3 将库作为纯标头库和模块进行构建

C:\modules> type animals.h
#pragma once
#include <stdio.h>
#ifndef ANIMALS_EXPORT
#define ANIMALS_EXPORT
#endif
namespace animals { namespace impl {
// Please don't look here
}}
ANIMALS_EXPORT namespace animals {
// This is all yours
}

现在,任何不熟悉模块或缺少足够实现的开发人员都能够简单地包括 animals.h 标头并如使用其他任何纯标头库一样使用它。但是,我可以更新模块接口以定义 ANIMALS_EXPORT,使同一个标头可以生成一组导出的声明,如下所示:

C:\modules> type animals.ixx
module animals;
#define ANIMALS_EXPORT export
#include "animals.h"

和许多现在的 C++ 开发人员一样,我也不喜欢宏,我更愿意生活在一个没有宏的世界中。但是,当您将库转换为模块时,宏仍然是一个有用的技术。最大的好处是,虽然包括 animals.h 标头的应用会看到良性宏,但它们对只是简单地导入模块的标头却完全不可见。创建模块的元数据之前会删除宏,这样,宏就绝对不会渗透到应用或其他任何可能使用宏的库或模块中了。

模块是一个受欢迎的 C++ 添加,我希望将来的编译器更新带有完整的商业支持。现在,您可以和我们一起进行实验,推动 C++ 标准的发展,我们的前景是开发出用于 C++ 的模块系统。您可以通过阅读技术规范 (goo.gl/Eyp6EB) 或观看 Gabriel Dos Reis 去年在 CppCon 的谈话 (youtu.be/RwdQA0pGWa4) 详细了解模块信息。

协同例程

虽然协同例程(以前称为可恢复函数)的存在时间比 Visual C++ 长一些,但我对于在 C++ 语言中支持真正的协同例程这一前景保持乐观,这是因为它在基于堆栈的 C 语言设计中有很深的根基。当我考虑该写些什么的时候,我渐渐明白,关于这一主题,我不止为 MSDN 杂志写过一篇文章,而是至少写过四篇文章。我建议您从 2015 年 10 月刊 (goo.gl/zpP0AO) 中最近的那篇文章开始读起,我在那篇文章中介绍了 Visual C++ 2015 中提供的协同例程支持。与其回顾协同例程的优势,我们还是进行一些深入的探讨吧。C++ 17 采用协同例程的其中一个挑战在于,标准化委员会不愿意提供自动类型推断。编译器可以推断协同例程的类型,这样,开发人员就不需要考虑可能的类型了:

auto get_number()
{
  await better_days {};
  return 123;
}

编译器不只是能够生成适合的协同例程类型,这无疑是受 C++ 14 的启发,C++ 14 规定,函数可以推断其返回类型:

auto get_number()
{
  return 123;
}

但标准化委员会仍然不接受将这一想法扩展至协同例程。问题在于,C++ 标准库也不提供适合的候选对象。最接近的可能性是有着繁重的实现和不实用设计的 std::future,但它非常笨拙。在异步流(由产生值的协同例程生成,而不是简单地异步返回单个值)中这一方法也没有太大的帮助。所以,如果编译器不提供类型,而 C++ 标准库也不提供适合的类型,那么若我想在协同例程上取得进展,就需要更深入地了解它的实际运作方式。想象一下,我有以下虚拟等待类型:

struct await_nothing
{
  bool await_ready() noexcept
  {
    return true;
  }
  void await_suspend(std::experimental::coroutine_handle<>) noexcept
  {}
  void await_resume() noexcept
  {}
};

它不用进行任何操作,但我可以通过等待构造协同例程:

coroutine<void> hello_world()
{
  await await_nothing{};
  printf("hello world\n");
}

同样,如果我依赖编译器自动推断协同例程的返回类型,并选择不使用 std::future,那我要如何定义该协同例程类模板?

template <typename T>
struct coroutine;

由于本文的篇幅有限,我们就简单地看一看协同例程不返回内容或返回 void 的示例吧。特殊之处如下:

template <>
struct coroutine<void>
{
};

编译器做的第一件事是,在协同例程的返回类型上查找 promise_type。还有另外一些绑定方式(尤其是如果您需要将协同例程支持改造到现有的库时),但是由于我所写的内容是协同例程类模板,因此,我只能在此简单声明:

template <>
struct coroutine<void>
{
  struct promise_type
  {
  };
};

接下来,编译器会在协同例程承诺上查找 return_void 函数,至少会查找不返回值的协同例程:

struct promise_type
{
  void return_void()
  {}
};

return_void 不用进行任何操作,不同的实现可以将其用作状态更改的信号,指示协同例程的逻辑结果已准备好接受检查。编译器还会查找一对 initial_suspend 和 final_suspend 函数:

struct promise_type
{
  void return_void()
  {}
  bool initial_suspend()
  {
    return false;
  }
  bool final_suspend()
  {
    return true;
  }
};

编译器使用它们向协同例程注入一些初始和最终代码,以告知计划程序是否要在挂起状态下启动协同例程,以及是否要在完成之前挂起协同例程。这对函数确实可以返回可等待类型,因此,编译器实际上可以在两个函数上等待,如下所示:

coroutine<void> hello_world()
{
  coroutine<void>::promise_type & promise = ...;
  await promise.initial_suspend();
  await await_nothing{};
  printf("hello world\n");
  await promise.final_suspend();
}

是否等待和注入挂起点,这都取决于您想要实现的目标。特别是,如果您需要按照完成情况查询协同例程,请确保有一个最终悬停;否则,在您有机会查询承诺捕获的任何值之前,协同例程就会被破坏。

编译器查找的下一个内容是从承诺中获得协同例程对象的方式:

struct promise_type
{
  // ...
  coroutine<void> get_return_object()
  {
    return ...
  }
};

编译器确保 promise_type 被分配为协同例程帧的一部分。然后,它需要一种从承诺中生成协同例程的返回类型的方法。这会被返回到调用方。在此,我必须依赖一个由编译器提供的非常低级的帮助器类,它被称为 coroutine_handle,目前在 std::experimental 命名空间中提供。coroutine_handle 表示协同例程的调用;因此,我可以将这一句柄存储为我的协同例程类模板中的成员:

template <>
struct coroutine<void>
{
  // ...
  coroutine_handle<promise_type> handle { nullptr };
};

我用 nullptr 初始化句柄,以指示协同例程当前未进行传输,但我也可以添加构造函数,明确地将句柄与新构造的协同例程相关联:

explicit coroutine(coroutine_handle<promise_type> coroutine) :
  handle(coroutine)
{}

协同例程帧有点像堆栈帧,但它是动态分配的资源且必须进行销毁,所以我一般使用构造函数:

~coroutine()
{
  if (handle)
  {
    handle.destroy();
  }
}

我还应该删除复制操作并允许移动语义,但这些你们都懂的。现在,我可以实现 promise_type 的 get_return_object 函数,以将其作为协同例程对象的工厂:

struct promise_type
{
  // ...
  coroutine<void> get_return_object()
  {
    return coroutine<void>(
      coroutine_handle<promise_type>::from_promise(this));
  }
};

现在,我应该已经拥有编译器的足够内容,可以生成协同例程并启动了。此处又是一个带有简单的主函数的协同例程:

coroutine<void> hello_world()
{
  await await_nothing{};
  printf("hello world\n");
}
int main()
{
  hello_world();
}

我还未对 hello_world 的结果进行任何处理,但运行此程序会调用 printf,并将熟悉的消息打印至控制台。这是否意味着协同例程已经真正完成了? 我可以向协同例程进行提问:

int main()
{
  coroutine<void> routine = hello_world();
  printf("done: %s\n", routine.handle.done() ? "yes" : "no");
}

这一次,我只询问协同例程是否完成,但不进行任何操作,它果然完成了:

hello world
done: yes

请回顾一下,promise_type 的 initial_suspend 函数返回 false,所以协同例程不会自行开始生命挂起。再请回顾一下,await_nothing 的 await_ready 函数返回 true,所以也不会引入挂起点。最终结果就是,协同例程确实同步完成,因为它没有理由得出其他结果。优点在于,编译器可以优化行为同步的协同例程,并应用所有相同的优化使直线代码可以如此迅速。但是,这并不令人兴奋,所以,让我们添加一些挂起,或至少添加一些挂起点。这和将 await_nothing 类型更改为持续挂起一样简单,虽然它与这一操作无关:

struct await_nothing
{
  bool await_ready() noexcept
  {
    return false;
  }
  // ...
};

在本例中,编译器将把此等待对象视为尚未就绪,并在恢复之前返回调用方。现在,如果我返回简单的 hello world 应用:

int main()
{
  hello_world();
}

我将会失望地发现,该程序未打印任何内容:原因显而易见: 协同例程在调用 printf 之前已经挂起,且拥有该协同例程对象的调用方没有给它恢复的机会。当然,恢复协同例程和调用句柄提供的恢复函数一样简单:

int main()
{
  coroutine<void> routine = hello_world();
  routine.handle.resume();
}

现在,hello_world 函数会在不调用 printf 的情况下再次返回,但恢复函数将使协同例程得以完成。为进一步说明,我可以在恢复前和恢复后使用句柄的 done 方法,如下所示:

int main()
{
  coroutine<void> routine = hello_world();
  printf("done: %s\n", routine.handle.done() ? "yes" : "no");
  routine.handle.resume();
  printf("done: %s\n", routine.handle.done() ? "yes" : "no");
}

结果清晰地显示出调用方和协同例程的交互:

done: no
hello world
done: yes

这一操作非常方便,尤其是在缺少复杂的 OS 计划程序和线程的嵌入式系统中更是如此,因为我可以非常方便地编写轻型协作多任务处理系统:

while (!routine.handle.done())
{
  routine.handle.resume();
  // Do other interesting work ...
}

协同例程并不神奇,也不需要复杂的计划或同步逻辑才能工作。用返回类型支持协同例程涉及到用接受值并将其存储在承诺中的 return_value 函数替换 promise_type 的 return_void 函数。这样,当协同例程完成时,调用方就能检索该值。产生值流的协同例程需要在 promise_type 上有相似的 yield_value 函数,但本质上是相同的。编译器提供给协同例程的挂接非常简单,但十分灵活。我只在这一简短的概述中谈到了一点儿皮毛,但我希望它能让您领略到这一新语言功能的不可思议之处。

Microsoft C++ 团队的另一位开发人员 Gor Nishanov 会继续将协同例程推向最终的标准化。他甚至致力于为 Clang 编译器添加协同例程支持! 您可以通过阅读技术规范 (goo.gl/9UDeoa) 或观看 Nishanov 去年在 CppCon 的谈话 (youtu.be/_fu0gx-xseY) 详细了解协同例程。James McNellis 也会在 C++ 会议上进行有关协同例程的对话 (youtu.be/YYtzQ355_Co)。

Microsoft 中正在发生许多有关 C++ 的事。我们正在添加新的 C++ 语言功能,包括 C++ 14 中的变量模板,它让您可以定义一系列变量 (goo.gl/1LbDJ2)。Neil MacIntosh 正致力于提出有关 C++ 标准库的新方案,以实现对字符串和序列进行边界安全地查看。您可以在 goo.gl/zS2Kaugoo.gl/4w6ayn 上研读 span<> 和 string_span,两者甚至均有可使用的实现 (GitHub.com/Microsoft/GSL)。

关于后端,我最近发现,当涉及到在使用字符串文本时优化掉对 strlen 和 wcslen 的调用时,C++ 优化器比我想的要智能得多。这并不新奇,虽然这是一个严守的秘密。新增的功能是,Visual C++ 终于实现了完整空库的优化,这一功能已经缺乏了十多年。将 __declspec(empty_bases) 应用于零偏移布局的所有直接空库类的类结果。但这并非默认设置,因为这需要对编译器进行主版本更新才能引入这一巨大更改,而仍然有部分 C++ 标准库类型假定旧布局。但是,库开发人员终于可以利用这一优化了。用于 Windows 进行时的现代 C++ (moderncpp.com) 从这一优化中受益最多,并且这其实也是这一功能最终被添加到编译器中的原因。我曾在 2015 年 12 月刊中提及,我最近加入了 Microsoft Windows 团队,基于 moderncpp.com 为 Windows 运行时构建新的语言投影,而这也有助于推进 Microsoft 的 C++ 发展。毫无疑问,Microsoft 非常关注 C++。


Kenny Kerr是 Microsoft Windows 团队的软件工程师。他的博客网址是 kennykerr.ca,您可以通过 Twitter @kennykerr 关注他。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Andrew Pardoe