concept 和 std::ranges 时代的文档

标题:Documentation in the Era of Concepts and Ranges

日期:2021/12/23

作者:Christopher Di Bella & Sy Brand

链接:https://www.youtube.com/watch?v=nm45t2fnUms

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:std::ranges 本身是高强度使用 concept 自文档化的,所以挑了一个解说视频。


大家好,我是 Christopher Debella,Google 的一名软件工程师。

我的名字是 Sai Brand,我是微软的 C++ 开发者布道师。

今天,我们将和大家探讨在概念(concepts)和范围(ranges)时代下的文档化问题。

我曾在 Twitter 上寻找我发过的推文,想用来作为这次演讲的引子,然后我找到了这条。

我喜欢用构思演讲点子时发的推文来开启我的会议演讲,但我没找到合适的,所以我发了这条推文,这样我的 CppCon 演讲开头就有东西可放了。

于是,我们现在就在 CppCon 的演讲现场了。但说真的,这一切背后的真正动机是:概念(concepts)究竟如何影响我们的 C++ 文档?

我们将从多个不同层面来探讨这个问题。

我们首先会从现状(state of the art)开始,看看当今的 C++ 项目在文档方面是怎么做的。然后,我们会转向什么是好的文档,探讨什么让文档变得优秀且合乎目的。接着,是概念如何与文档互动,这里我们指的是 C++20 的概念。之后,我们会特别关注范围(ranges),将其作为一个案例研究,当然,指的是 C++20 的范围。最后,我们将展望未来,探讨我们应该如何改变我们编写 C++ 代码文档的方式。

在进入正题之前,我们应该先声明一点:我们在此所说的一切都仅代表我们个人观点,不代表我们雇主的立场。我们确实会对一些现有库提出一些批评。我们认为这些库都非常出色,但这些批评是出于善意的。当我们谈论这类事情时,我们是希望改善现状,而不是抨击任何现有的内容。

文档的类型

那么,让我们从讨论有哪些不同类型的文档开始。

我们有四大类文档,首先是设计(design)文档,它详细说明了你所做设计。然后是实现(implementation)相关的,比如代码注释、内部 API 和外部 API。在这次演讲中,我们将主要关注内部 API外部 API,但很多主题可以超越这个范围,并与所有四个文档领域相关。

如果我们再往下细分,我们可以看到另外四种文档类型。这更多地是关注用户的需求。

  1. 面向学习的教程(Learning-oriented tutorials):用户零基础,我们想让他们达到能动手做的状态。

  2. 面向目标的“如何做”指南(Goal-oriented how-to guides):这些指南帮助那些已经知道自己想去哪里,但不知道如何到达的人。它们能把用户从 A 点带到 B 点。

  3. 面向理解的讨论(Understanding-oriented discussions):这些内容真正地解释了某些决定背后的意义和作者的初衷。

  4. 面向信息的参考资料(Information-oriented reference material):这有点像你 API 的百科全书。它涵盖了前三类没有涉及的所有内容。

关于这类东西,有很多信息需要理解。我们在这里将主要讨论面向信息的参考资料,但我们谈论的很多内容也同样适用于其他三类。如果你想了解更多关于这方面的内容,你绝对应该去看看“Write the Docs”大会,它对所有这些都做了比我们一小时内能做的更详细的介绍。

现状:当今的 C++ 文档

好了,让我们来看看现状。我们今天处于什么位置?当下的 C++ 项目在文档方面是怎么做的?

我们将看几个例子,其中一些你可能听说过。

一个是来自 Google 的 Abseil,这是一个非常流行的库。这是它用于分割字符串的 str_split 函数的部分文档。值得注意的是,这个库的整个文档都非常侧重于散文式描述和示例驱动。它不倾向于那种逐个函数签名进行分解、解释每个参数含义等的方法。它更多地是通过英文描述和示例来驱动,试图让读者从文档中提取他们完成工作所需的确切信息。

这份文档是由一个 Markdown 文件生成的,是手写的文档。它不是从头文件中提取的。但头文件中也有文档,与网页上的内容非常相似,但又不完全相同。所以这是一种分离式文档:一部分在头文件中,供直接在代码上工作的人使用;另一部分在网页上,供浏览网页的人使用。这两部分是分开维护的

另一个例子是 format-lib,这是一个类型安全的、类似 Python 风格的字符串格式化库,它影响了 C++20 中引入的 std::format。它的文档采用了这样的方法:首先是一个概述,解释我们为什么需要这个库?为什么应该使用它?用户怎么说?然后是一个快速入门指南,告诉你如何开始。

之后,它会进入更熟悉的 API 文档形式。但同样,它的顶部也采用了散文驱动的方式。我们有一些示例,并且它没有像 Doxygen 生成的文档那样,为每个函数签名分配大量空间。我个人非常喜欢这种文档风格,稍后我们会讨论我为什么喜欢它。

这个文档实际上也是用 Doxygen 生成的,但还用了别的东西。这里的 RST 是 reStructuredText 的缩risy。RST 内容通过 Doxygen 从头文件中提取出来,然后被导入到一个名为 Sphinx 的工具中,这是 Python 社区为文档制作的工具。我非常非常喜欢 Sphinx,它很棒。它看起来像这样:你有一些描述系统错误的 reStructuredText,然后 Sphinx 和 Doxygen 之间有一些桥梁,可以让你导入从 Doxygen 提取的文档,并将其制作成 Sphinx 文档。我非常喜欢这种风格。

然后我们有像 LLVM 这样的项目,它更像是你可能习惯的那种经典的 Doxygen 文档。我这里选了一个不太公平的例子,只是因为我们稍后会拿它来“开刀”。LLVM 的一些文档非常出色,但这一部分相当稀疏。Doxygen 倾向于为每个函数提供独立的条目,并带有超链接。然后给它们大块的版面,你可以在其中查看它们的参数、链接回源代码、查找引用等。虽然这里信息不多,但每个条目都占用了很大的空间。

接着是像 Catch2 这样的库,它真正掌握了我们之前提到的“如何做”指南的精髓。这篇文档介绍了如何理解 CHECKREQUIRE 的工作原理,以及如何编写一个简单的测试。它通过 Markdown 实现,完全是散文式的,并带有示例。与 Abseil 一样,它完全没有过多地关注接口本身,只是告诉你使用我们 API 所需知道的一切。

他们还有一个很棒的迁移文档,清楚地告诉你如何从 Catch2 v2 升级到 Catch2 v3。它会带你走过所有必要的步骤,让你从使用库的第 2 版过渡到第 3 版。

当然,如果一篇题为《概念与范围时代的文档化》的演讲不至少讨论几个范围库及其文档,那就算不上一场好演讲。

我们要看的第一个是 Boost.Range 2.0,它是 Range-v3 的前身,而 Range-v3 又是我们现在标准库中内容的先驱。Boost.Range 2.0 的文档混合得很好。它提供了用法,让人们理解他们将要接触的内容,并将其与理解作者的动机相结合,比如他们为什么喜欢管道操作符(pipe operator)。

我们也可以看看 Range-v3 的文档,发现它开头非常有力,像 Boost.Range 2.0 一样,对范围是什么给出了很好的概述。但之后,它的参考资料就变得非常稀疏,并没有真正涵盖太多内容。我们会稍后更详细地分析这一点,但内容大致是这个样子。其原因是,它和我们之前看到的 LLVM 一样,是 Doxygen 驱动的。它的源码里有 addtogroupprecondition 这样的 Doxygen 指令。

但 Range-v3 也有一些有趣的文档形式。例如,如果你用过 Range-v3 并且出错了,你可能会意识到编译器会给你大量的模板诊断信息。如果你去看返回的诊断信息,你偶尔会看到这样一行全大写的文字:READ THIS。作者们发现,他们可以利用编译器向公众提供帮助信息的方式——因为这会捕获整行文本,包括注释部分——来告诉用户:“嘿,去读一下头文件里的这个东西。”然后你就会看到一段不错的散文式文档,写着“当用管道连接一个范围适配器时,你需要满足 viewable_range 概念的要求”,接着用一种更易于理解的方式解释了这意味着什么。

此外,Range-v3 有很棒的测试,一个很棒的测试框架,这也可以作为文档。这可能是该库中最好的文档形式,而且非常全面。例如,我从 chunk_view 测试中截取的这个 chunk_view 示例,一旦你了解了词汇,你很快就能看出这个特定的范围将是一个随机访问范围,它将内容分组为三个元素的块,然后继续。当我们不再有三个元素的组时,它就简单地返回剩下的任何内容。

所以,这就是当前技术水平的概貌。

什么是好的文档?

现在,我们将专注于好的文档。什么是好的文档?

我查阅了许多不同的资料,反复看到的主要是以下几点。我们会逐一分解,它们是:合乎情境(fit for context)、清晰(clear)、易于消化(digestible)、完整(complete)、易于访问(accessible)和保持最新(up to date)

1. 合乎情境 (Fit for Context)

让我们逐一分析这些特质,以便你明白我的意思。当我们说“合乎情境”时,我的真正意思是,如果我们回顾几页前的幻灯片,我们谈到了文档的种类:设计文档、实现文档、如何做指南、参考资料。这些都是为不同受众准备的,对吧?比如,不参与你库开发的人很可能不需要实现文档,而参与开发的人则很可能需要。你文档的初学者和专家也可能想要不同类型的文档。你需要考虑你的受众是谁,你需要考虑谁在消费你的文档以及他们需要什么。

这是我们这次演讲的第一个关键点:

了解你的受众。 明确地思考你为谁编写文档,并决定如何最好地针对该受众。

我们确实看到了关于 cppreference 上 std::ranges 条目的问题,我们稍后肯定会讨论这个。

2. 清晰 (Clear)

清晰度实际上是指你如何向用户传达信息,以及他们是否能理解你所谈论的内容。

让我们回到 Catch2 的例子。Catch2 在人们会去阅读的第一份文档中就提出了一个问题:“为什么我们还需要另一个 C++ 测试框架?” 接着,它继续讨论,我们已经有了所有这些其他的测试框架,Catch2 带来了什么新东西,为什么在一个已经过度饱和的测试框架海洋中,我们还需要一个全新的库?

它通过使用项目符号和散文来分解这个问题,真正强调了其关键特性,以及它如何将其他库可能没有做的东西整合在一起。所以当你读完这些,你会有一个清晰的概念,你能回忆起 Catch2 带来了哪些其他库可能没有(或者没有作为一个整体提供)的功能。

然后是用户入门教程,它非常清晰,因为它让人们动手去做,明确指出你需要跟着做。它鼓励你实践他们正在做的事情,真正手把手地带你进入一个能使用这个库作为你的测试框架的功能状态。

如果我们回到“自然表达式”页面,它只讨论 CHECKREQUIRE 表达式,它们是什么意思,以及你为什么想用它们。所以,它不仅告诉你是什么,还告诉你为什么怎么做,并且为不同的受众在一定程度上分开了这些内容,这又回到了我刚才谈到的观点,即你需要先了解你的受众,然后才能开始与他们沟通。这样他们才能理解你想要告诉他们关于你库的信息。

3. 易于消化 (Digestible)

让我们来谈谈易消化性,并引用一些我们已经看过的例子。我们看过了 LLVM。这里的易消化性有几个方面。我认为 Doxygen 在这里非常清晰地划分了所有这些不同的部分,这确实有助于你快速浏览列表并找到你要找的东西。它确实为这些内容留出了很多空间,这可能会降低其易消化性,但它们也提供了许多超链接来帮助你四处跳转。所以我们在考虑,别人应该如何阅读和理解这个?它会是一大堆文字墙吗?它会是超链接的吗?我是否为其提供了清晰划分的章节?诸如此类,思考你文档的布局以及如何使其更易于消化。

我再次认为 format-lib 在这方面做得很好。这些章节划分清晰,但又不过分浪费空间。浏览起来很方便,也很容易理解你所在的位置、正在看什么以及在找什么。我也很喜欢它将散文、签名和示例分开的方式。对我来说,阅读这份文档非常愉快。

这些库采取了略有不同的方法,它们各自都有优缺点。在这次演讲中,我们不会告诉你该用什么工具来编写文档。我个人非常喜欢 Sphinx 和手写的文档,我认为这比那种自动生成的东西更能帮助用户。但我们希望你做的是:

考虑你可用的工具,并利用它们来最大化你的文档质量。

不要教条主义。在了解你的受众之后,决定什么工具适合你,以及你想要告诉用户什么,然后利用这些工具来最大化你的文档质量。我们现在也在讨论什么构成高质量的文档,所以请在后续内容中记住这一点。

4. 完整 (Complete)

还有完整性的问题。不是要挑剔 LLVM 的东西,但你看,这个文档是不完整的。这里缺少很多信息。如果我想调用这个 LLVMGetBitcodeModule2,我缺少很多信息。也许我知道这些,因为我已经了解 LLVM 的其他部分,我知道,哦,它可能要求内存缓冲区这样设置,这个 ModuleRef 应该来自另一个地方。但是,这对用户要求太高了。这里可能缺失了大量的信息。

5. 易于访问 (Accessible)

一旦你有了完整的文档,你真的需要考虑可访问性。可访问性有两个不同的维度,我们依次来看。

让我们以在 C++ 中分割字符串为例,因为我们谈到了 Abseil 有一个字符串分割功能。现在,在 C++ 中分割字符串还有许多其他流行的方法。让我们上网搜索“如何在 C++ 中分割字符串”。这是网络搜索的第一页,没有任何流行的东西出现。没有关于标准库分割、Abseil 分割、Boost 分割或任何其他在这方面真正流行的库的内容。有很多关于如何分割字符串的问题,但未必是关于有哪些常规方法。

幸运的是,C++20 引入了一种方法。所以我们直接传送到 cppreference,看看 C++20 给我们的 split_view。假设我们还不知道如何使用这个库。我们首先看到的是一个接口,它并没有特别描述性,还有很多文字。所以我们向下滚动。我们想再向下滚动一点。我们想现在就向下滚动到……好吧,我们滚动了好几次。我想我们总共滚动了四次,才找到那个向我们展示为了分割字符串需要做什么的示例。

所以,这里可访问性的概念是,内容需要易于找到,并且易于你的受众理解。cppreference 的受众非常广泛。它不仅面向初学者,也面向那些只需要查找几件事就继续工作的专家。但信息可能没有以对每个人都可访问的方式组织。它首先关注接口,这使得已经知道自己在找什么的人更容易上手,而把初学者放在了最后,因为他们需要一直向下滚动到最后。这是可访问性的第一个维度,确保你的受众能够访问到绝对适合他们的信息。

第二个维度是为人们提供的可访问性。不是每个人都和我们一样,所以我们需要确保我们编写的文档能够迎合尽可能广泛的受众。这意味着我们需要考虑诸如使用文本以便屏幕阅读器可以拾取内容,而不是使用图片。这不仅意味着屏幕阅读器,我们还可以增加字体大小而不会破坏分辨率。如果图片是绝对必要的,那么我们应该考虑提供 alt 文本,这样屏幕阅读器就能拾取图片应该显示的内容。

我们不会过多地深入这个话题,因为它非常复杂,我们在这次演讲中没有足够的篇幅来讨论它。它本身就可以成为一个独立的演讲。所以我们将把可访问性指南的讨论推迟到“Write the Docs”大会,在那里你可以学到更多,他们会为这个特定的可访问性维度给予应有的时间。

6. 保持最新 (Up to Date)

最后,好的文档应该是最新的。

如果我们回到 Abseil 的例子,你会记得 Sai 提到 Abseil 有一个可以在网上找到的散文式文档,同时在 str_split 所在的头文件中也有基于注释的文档。这是我们俩都非常喜欢的一点,我们稍后会解释原因,但这确实需要相当高的警惕性。因为可能有人决定为 Abseil 贡献代码,他们可能会以某种方式更新接口,然后他们看到文档在头文件中,于是头文件被更新了。但他们没有意识到网页文档不是从头文件文档中自动生成的,不像 format-lib 那样。这是两个需要分开维护的东西。于是,维护的负担就落在了维护者身上。如果维护者没有注意,他们批准了合并请求并将其合并到主分支,那么文档现在就不同步了。因为代码库中的文档是正确的,而网页上的文档突然变得过时了。

过时的文档比没用还糟糕,因为它会给人们关于接口的错误印象。所以,尽管我们是这种多模态文档的粉丝,但你必须确保你所有的文档都是最新的,才能被认为是好的文档。

我们之所以喜欢这种多模态文档,是因为它迎合了不同的受众。几周前我们做了一项调查,了解用户喜欢看到什么。我们可以看到绝大多数人希望有外部文档。其中,一小部分多数人也希望有内部文档。我们把外部文档定义为可以在网上轻松查阅的东西,而内部文档指的是头文件里基于注释的内容。不同的受众想要不同的东西,但几乎每个人都希望有外部文档。

我们真正想说的是:

你应该从多个维度来衡量你的文档。

这意味着你要确保你的文档能够被尽可能广泛的受众访问,并且永远不会过时。你要确保它能迎合尽可能多的人,并且始终保持最新。这就是好的文档,即从我们刚才讨论的所有多个维度来衡量你的文档质量。

概念如何与文档互动?

现在我们进入下一部分,概念如何与文档互动。那么,Chris,什么是概念?

一个概念(concept)表达了对某种算法的要求。概念被分解为三种要求:语法要求、语义要求和复杂度要求。如果一个类型满足了我们刚才提到的所有要求,我们就说这个类型 建模(model) 了这个概念。

sized_range 概念为例。我们有一个类型 R 和该类型的一个对象。我们说,对 Rsized_range 概念,文档化了以下几点:

  • R 是一个范围(range),这意味着我们可以对 R 调用 ranges::beginranges::end

  • 我们还需要能对 R 调用 ranges::size

  • 当我们调用 ranges::size 时,它将在摊销常数时间内返回 R 包含的元素数量。

  • ranges::size 不会修改 R

  • 此外,如果 R 建模了 forward_range 概念,那么 ranges::size 总是可用的。

让我们看一个概念如何与文档互动的例子。考虑这个函数 register_callback

template<typename F>
void register_callback(F f);

这个函数接受某个类型 F 的对象 f。我们对这个类型 F 知道些什么?也许我们可以直觉地认为,既然它叫 register_callback,类型名是 F,那么它可能是某种函数。这可能是个合理的猜测,但我们对这个函数知之甚少。如果我们想告诉用户,我们可以给他们一些文档,比如“你必须传入一个接受 void* 的回调函数”。比如说,我们用它来向一个 C API 传递东西。但现在我们没有这些,只有一个 register_callback 函数。

那么,假设我们传入一个接受 int 而不是 void* 的闭包,会发生什么?编译器会向我们大喊,对吧?我们得到了一个编译错误,需要做一些侦探工作来弄清楚这是什么意思。你可以在底部大致看出来,“无法将 void* 转换为 int 作为第一个参数”。所以你大概能猜到我们传入了一个期望 int 的东西,而它想要一个接受 void* 的东西。

我们再想一想。如果我们用一个概念来修饰 register_callback 呢?

template<std::invocable<void*> F>
void register_callback(F f);

现在,这个 std::invocable 表明 F 是一个类型,其实例可以被一个 void* 类型的实例调用(或 invoke)。所以它可以是函数指针、闭包、函数对象,任何可以用 void* 调用的东西。

这既是给用户的文档,也是给编译器的文档,对吧?如果我们看到这个,并且知道 std::invocable 的意思,那么这就成了我和这个库的实现者之间、其他用户和编译器之间的共享词汇,是所有人之间的共享词汇。

这在编译器的错误信息中得到了体现。这是来自 Clang 的。Clang 说,如果我们做同样的事情,传入一个接受 int 而不是 void* 的东西,它会说:“候选模板被忽略:约束不满足,因为 std::invocable 对我们的 lambda 和 void* 的求值结果为 false。” 我认为这是一个相当清晰的编译错误。

我们开始把概念看作是同时与用户和编译器沟通的工具。让我们看一个稍微复杂一点的例子。假设我们有一个名为 cartesian_product 的函数,它接受一堆范围并返回表示它们笛卡尔积的东西。

template<std::ranges::viewable_range... Vs>
    requires (std::ranges::forward_range<Vs> && ...) && (sizeof...(Vs) > 0)
auto cartesian_product(Vs&&... vs);

它有一些更复杂的约束。我们所有的 Vs 都必须是 viewable_range。这是我们需要理解的,以便它成为我们、用户和编译器之间的共享词汇。一旦我们理解了 viewable_range 是指一个不拥有其元素的范围(比如它是一个视图,一个范围视图适配器),或者它可能是一个左值(所以它不会拥有一些会在某个时刻超出作用域的内存),那么它就是一个 viewable_range。一旦我们理解了这一点,我们就知道我们要求的是什么。它还要求所有的 Vs 都是 forward_range,并且我们至少传入一个范围。

一旦我们理解了这些,作为用户,我们就能明白我们在说什么。编译器也明白。如果我们做一些像把一个右值 vector 传给这个 cartesian_product 的事情,一个右值 vector 不是一个 viewable_range,它会超出作用域。所以我们得到编译错误。它说:“约束不满足,因为 std::vector<int> 不满足 viewable_range。”

所以我们真正想说的是:

概念是一个工具,你可以用它来同时向用户和编译器表达约束。

当我们之前说“使用可用的工具来最大化文档质量”时(比如谈到 Doxygen、Sphinx 等),概念可以被用作一个文档工具。我们很快就会在从讨论概念如何与文档互动,转向我们的案例研究——C++20 范围时,对此进行扩展。

案例研究:C++20 范围 (Ranges)

那么,Chris,什么是范围?

范围本质上是一个类型,或者一对类型,它允许我们遍历某个数据结构的元素。范围由一个指向我们称之为“范围”中第一个元素的迭代器(iterator),以及一个描述结束范围规则的 哨兵(sentinel) 来表示。我们在 C++ 中描述一个范围类型的方式是,我们可以对该范围调用 ranges::begin,这将返回迭代器;然后我们可以对该对象调用 ranges::end,这将返回表示结束规则的哨兵。

我们有不同种类的范围,这些不同种类的范围提供不同的功能。我们已经看到 sized_range 是一个也允许我们对范围对象调用 ranges::size 的范围。但我们也有像 random_access_range 这样的东西,它要求其迭代器必须是随机访问迭代器。这意味着我们可以做一些很酷的事情,比如能够任意跳过一定数量的元素,而不是必须一个一个地按顺序遍历它们。这意味着我们可以在常数时间内从 A 点跳到 B 点。

一旦我们知道了这些,我们就可以思考应该如何文档化范围。

这里有个背景故事。去年,在 COVID 期间,我被邀请去一所大学的 C++ 101 课程做志愿者讲师,学生们已经有两年的编程经验。我想在那门课上教范围,但发现 cppreference 上关于范围的文档并不多。所以我最终做的是,我去看了 C++98/17 的算法是如何文档化的,并以此为基础来为基于范围的算法编写文档。

我最终得到的是这个:

是的,我昨晚给一个朋友看了这个,他们说他们这辈子从没见过这么多冒号。所以我有点担心,我去年开始写的这个文档不符合我们制定的指导方针。你觉得怎么样?

嗯,我是说,技术上讲,我们确实说过概念是一个文档工具。所以,我想这应该没问题吧?

好吧,好吧。如果我没理解错你的话,你的意思是:

概念可以被用作文档工具,但像任何其他工具一样,文档工具也可能被滥用。

这意味着概念也可能被滥用。这基本上意味着,仅仅因为我们说概念可以成为文档工具,不代表你就应该开始到处用概念来文档化东西,因为那最终会使文档混乱不堪,读起来非常吓人。

这回答了之前的问题吗?我想我们现在会回答之前的问题。这应该是什么样子的?

让我们想想这应该是什么样子的。对吧,Chris,你有什么建议吗?

是的,经过一番思考,我有了这个想法:把这个 transform 示例从最底部移到最顶部。我们之前谈到了可访问性,以及初学者需要滚动浏览大量内容。滚动浏览那个 split_view 远没有这个 transform 示例那么困难。所以我们做的是,我们把示例移到了最顶部,并将 API 放在下面的一个部分里,并明确地标记它为 API。

你觉得这个比之前那个怎么样?

我认为这绝对是一个进步。但我认为,为了绝对清晰并确保其合乎目的,我们缺少了对这到底是什么的描述。我们有点假设来这里的人已经知道他们想做什么。我们没有任何关于 std::ranges::transform 实际上是做什么的高层解释,对吧?

那么,我们把你说的应用到 cppreference 上怎么样?我们把描述放在顶部,解释 ranges::transform 应该是做什么的,用户可以从中期待什么。而且我在编辑时意识到,我个人特别不喜欢记住东西属于哪个头文件。我非常依赖 cppreference 来查找头文件。因为那部分文字很小,并且不会妨碍任何其他内容,我有了这个想法:把定义这些东西的头文件放回最顶部。然后是描述,用更大的字体来吸引人们的注意。但那些经常忘记东西、只想图个方便的人仍然可以与它互动。我认为这兼顾了两方面的优点,同时满足了两种受众。你觉得怎么样,Sai?

我确实认为这是一个巨大的改进,即使没有真正改变页面的内容。仅仅是再次思考我们的受众,思考人们会来这个页面寻找什么,这是一个巨大的改进。我们考虑了我们的章节,我们组织了信息,考虑了我们的受众,考虑了使其易于消化、清晰、易于理解。我们了解了我们的受众,对吧?我们一直都在考虑受众。所以我认为这在一定程度上回答了开头那个问题。我认为即使只是重构信息也能产生巨大的差异。

但我们接下来还要考虑如何以稍微不同的方式使用概念来进行文档化。

一个问题是:我的范围可以是 const 的吗? 就像 Chris 说的,我们有很多不同种类的范围。如果我有一个范围,我可以对它进行 const 限定然后遍历我的成员吗?这在某些范围中是可行的,而其他范围则不支持。我们怎么知道呢?我们想去查文档,看看这是否被支持,对吧?

让我们去 cppreference 看看我们的 join_view 是否可以是 const 可迭代的。好吧,我们首先需要知道的是,我们需要检查 join_viewbeginend 成员函数。所以我们向下滚动,然后打开 beginend 的子页面,然后看那些签名。然后我们需要知道,我们需要检查 beginendconst 限定版本。知道了这些,我们就能推断出,只要我们底层范围的 const 版本是一个 input_range 并且其引用类型是一个真正的引用(即不是代理对象),我们的 join_view 就是 const 可迭代的。

所以我们算是找到了答案。但这要求我已经有了一堆领域知识,还要求做一些侦探工作。也许有不同的方法来处理这个问题。也许还有其他类似的问题我们可能想问。你能想到任何吗,Chris?

哦,对。我想到的一个问题是:我们可以把 range.begin()range.end() 传递给一个经典的算法吗? 我们之前谈到,一个范围是一个有迭代器和表示结束的哨兵的东西。但在 C++98 中,我们的范围有一个表示开始的迭代器和一个指向最后一个元素之后位置的第二个迭代器来表示范围的结束。我们称之为公共范围(common range)。所以它是一类特殊的范围。像 vector 这样的东西就是一个公共范围,因为它有一对迭代器,而不是一个迭代器和一个不同的哨兵类型。

你可能会想,如果我们用范围,为什么还要用 C++98 的算法?也许有人写了一个 C++98 到 17 时代的库,他们还没有更新到基于哨兵的算法。所以这就是我们在这里考虑的事情。我们需要一个公共范围才能使用这种风格的算法。

让我们看看 lazy_split_view,看它是不是一个公共范围。事实证明,如果我们有一个底层范围既是 forward_range 又是 common_rangelazy_split_view,那么我们得到的 lazy_split_view 就是一个公共范围。但是,虽然第一个重载在某种程度上暗示了这一点,我们确实需要看一下散文文档。这还不算太糟,但第二个重载才是我们真正需要关注的,因为它的接口完全没有提供任何上下文。然后我们需要去看散文,我们看到它返回一个“外部迭代器或默认哨兵,取决于某些要求”。我们必须去看这个逻辑才能找出,我们实际得到的返回内容是什么。

我们看一下逻辑,我们看到它必须是一个 forward_range,然后当范围被 const 限定时也需要是一个 forward_range,再然后当它被 const 限定时还需要是一个 common_range。只有这样我们才能得到一个公共范围。否则我们得到的是一个非公共范围。

这非常冗长,对于任何人来说都很难读懂。我自己也读了几遍才明白它在说什么。所以我认为,它没有清晰地传达信息,也没有让人们理解这里发生了什么。他们必须经过相当多的努力才能到这里。

用户真正需要知道什么?

那么现在,我们应该思考,一个范围的用户真正需要知道什么?他们应该知道的东西包括:

  • 类型要求 (Type requirements): 这以概念的形式出现,包括所有三种要求。

  • 前置/后置条件 (Pre/post conditions)

  • 如何构造范围 (Construction)

  • 解引用迭代器会得到什么 (Dereference result): 是一个引用类型还是某种对象?

  • 范围的类别 (Range category): 是 input_range 还是 random_access_range

  • 范围是否有大小 (Sized)

  • 是否是公共范围 (Common range)

  • 是否可以 const 迭代 (Const-iterable)

  • 是否是可借用范围 (Borrowed range): 这是在 C++20 设计周期后期才引入的一个概念。

现有库的文档分析

让我们看看其他库是如何呈现这些信息的。我们从 Range-v3 的 stride_view 开始。stride_view 是一种跳过固定数量元素的方式。如果我们看这部分文档,我们可以看到它非常稀疏,并没有太多内容。所以它没有真正满足用户的需求,即告诉人们如何使用它,甚至它代表什么。

  • 合乎情境:否。

  • 清晰:否,缺乏清晰度。

  • 易于消化:否,因为几乎没什么可消化的。

  • 完整:否。

  • 易于访问:否,我花了些时间才找到它。

  • 保持最新:是,但这主要是因为它文档太稀疏的结果。

那么对于用户需要知道的内容呢?它确实有一些类型要求,底部说我们需要一个整数类的 difference_type,但它没有告诉我们关于范围的任何信息。它也没有告诉我们 stride 的前置条件(我们稍后会讲到)。没有关于如何构造或使用的信息。我们不知道解引用迭代器会得到什么。我们不知道它会是一个 input_range 还是一个有大小的 random_access_range。我们不知道它最终会是一个公共范围,或者我们是否可以迭代一个 const 版本的它。我们也不知道它是否是可借用的。

接下来,我们看看 Boost.Range 2.0 的 strided,并将其与 Range-v3 进行对比。

马上就能看到,这里有大量的文档,它详细介绍了 strided,不仅告诉你如何使用,还给了你一个例子。

  • 合乎情境:是。

  • 清晰:是,非常清晰。

  • 易于消化:是,它被分解成章节,有表格和项目符号。

  • 完整:否。它涵盖了像“前置条件是必须有一个非负整数”这样的事情,但没有告诉我们可以传入哪种范围。

  • 易于访问:部分是。因为它是 100% 的文本,这意味着屏幕阅读器或者在浏览器中放大到 200% 或 300% 都很容易。不幸的是,尽管这个 Boost 库有一个很棒的目录,我还是花了好几次点击才找到 strided 的文档。所以我只给它部分加分。

  • 保持最新:看起来是,这很好。

再来看看用户需要知道的内容。我们已经说过类型要求不存在。它确实涵盖了前置条件,这很棒。它向我们展示了如何构造,不仅使用构造函数,还展示了如何使用管道语法并对此进行了说明。不幸的是,它没有告诉我们引用类型,但它确实提到范围类别是单遍范围(single pass range),这本质上是 input_range 的另一种说法。它没有提到它是否有大小。至于最后三项:公共、const 可迭代和可借用,我会给它一点通融,因为 Boost.Range 2.0 我想是来自 2010 年代的,而这最后三项至少是 2014 年至今的产物。但这并不意味着我不应该提这些。通过说明“嘿,我们现在在 2021 年了,也许我们至少应该提到这个”,即使库本身不涵盖这些东西,文档也可以通过说明这些特性在本库中不存在来变得更完整。

我们建议的文档风格

那么,在我们看来,这些信息实际上应该如何呈现呢?

我花了很多时间思考我们应该如何真正地文档化这些东西。我们之前的一个关键点是:概念可以被用作文档工具。所以让我们思考如何应用概念以及我们一直在谈论的所有那些维度来改进这个文档。

我们将以 cycle_view 为例。这是 Range-v3 的,仅作比较。

我们会从一个高层描述开始:

cycle_view 将一个视图变成一个无限循环的视图。

然后我们会有一个示例。我认为这是一个很好的方式,既清晰,又合乎目的,也易于消化。大多数用户都喜欢示例,对吧?我们想要快速上手并运行一些东西。对于像 cycle_view 这样的东西,这非常完美。

// 示例代码
auto v = std::views::iota(1, 4) | std::views::cycle;
// v is {1, 2, 3, 1, 2, 3, 1, ...}

然后我们开始分解我们的概念属性

  • 类型要求:

    • V 必须是 forward_rangeview

  • 引用类型:

    • V 的引用类型相同。

  • 类别:

    • 如果 Vrandom_accesssized,则为 random_access

    • … 其他一些细节。

  • 其他属性:

    • 永不 sizedcommonborrowed

    • 如果 Vconst-iterable,则它也是 const-iterable

这些都是我们可以通过看签名推断出来的东西,但这只是用概念这个共享词汇把所有东西都摆出来了。如果我们理解 forward_range 是什么,我们就理解这份文档,编译器也一样。

然后我们告诉你如何构造它。我们说你可以不带参数地用管道连接它,我们给你一个例子,并展示如何用函数调用语法构造它。

我们说过要从多个维度衡量你的文档质量。让我们来思考一下这个例子:

  • 合乎情境: 我认为我们考虑了我们的受众,以及受众需要什么。

  • 清晰和易于消化: 我们划分了清晰的章节,使用了标题和项目符号。我们考虑了如何布局这些信息。

  • 完整: 我们考虑了用户实际使用这个库需要什么。

  • 易于访问: 这取决于你如何把它转换成网页。

  • 保持最新: 这是手写和手动维护的,所以如果你的类型签名有变动,可能会以不明显的方式影响这些属性,需要小心。

让我们看一个更复杂的例子:chunk_by_key_view。这个可能不像 cycle 那么直观。

chunk_by_key 是一个视图,它将范围分块为子范围,其中连续的元素共享由投影函数给出的相同键。

这是一个比喻,我们需要知道什么是投影函数之类的。所以我们有一个示例。我们有一些猫,比如 Marshmallow(棉花糖)。你可以打个招呼,这是 Marshmallow。他非常软,非常困。我们有猫,猫有名字和年龄。然后我们展示如何按年龄对这些猫进行分块。第一组是前两只 12 岁的,然后是另一只 9 岁的,然后是另外两只 12 岁的。你看它们被分成了三组。

然后我们分解我们的概念

  • 要求: forward_rangeview,以及一个稍微复杂的 indirect_unary_predicate。这个你可能需要了解,或者我们决定用散文来描述。

  • 引用类型: 开始变得有点复杂了。是 std::pair,包含调用函数的结果和迭代器的子范围。也许我们觉得这开始复杂了,所以我们给出一个例子:如果 Vvector<int>F 是从 intbool 的函数,那么你得到的是 pair<bool, subrange<vector::iterator>>。你可以看到这正处于我们考虑是纯粹用概念文档化,还是切换到其他方式的临界点。

  • 其他概念: 类别总是 forward,永不 sizedcommonconst-iterableborrowed

  • 构造: 展示如何构造和使用管道语法。

我认为这就是我们需要的所有信息。再次,我认为这份文档在“什么是好的文档”方面与前一个类似。我们深入思考了我们的受众,如果我们想让它变得更复杂,我们可能需要以稍微不同的方式显示内容或将某些东西分解出来。它同样需要手动维护,所以我们需要考虑到这一点。但我认为,通过采用概念这个共享词汇,为用户和编译器服务,并始终考虑用户、考虑我们的受众、考虑这些文档维度,这是文档化这类范围适配器的一种好方法。

我们应该如何改变?

那么,既然我们已经看了一些范围的例子,让我们想一想,我们应该如何改变我们文档化 C++ 代码的方式?你觉得呢,Chris?

作为用户,我们应该要求什么?

作为用户,我们应该要求实现者提供什么样的文档?我们应该要求我们的文档是合乎情境的、清晰易消化的、完整的、易于访问的和保持最新的。这基本上是我们整个演讲中一直在谈论的所有内容。

之前有个问题是,我们如何让像 cppreference 这样的网站改变他们的文档风格?有几种方法可以做到。这肯定取决于让他们去做,并向他们提出要求。在像 cppreference 这样的网站上有讨论页,你可以发表意见说,“嘿,我希望它能改变”。你可以去 CPP-Lang Slack 的 cppreference 频道(译者注:原文为Hatching Discord channel,但现在主要在Slack)讨论。有很多方法可以做到。它是一个 wiki,所以如果你觉得足够强烈,你也可以自己做修改。但讨论区是为这类事情获得支持的好方法。

但我们真正想强调的一点是,如果你不要求这些东西,它就不会变得更好。几年前,一位编译器开发者在一次 C++ 标准会议上说:“我们不把编译器错误信息做得更好的唯一原因,是用户不抱怨它们。” 这相当令人震惊。编译器错误信息是应该做得好并且有帮助的东西。所以你需要开始要求事情变得更好,这样世界才能进步。

以 Range-v3 基于头文件的那个东西为例,我们谈到你需要注意到那个 READ THIS,然后知道这意味着“请去这个头文件”。然后你才能看到那段非常棒的散文文档。如果编译器有一种方式,让库开发者能把这条信息直接放进你收到的诊断信息里,那会怎么样?这样用户就可以用散文形式看到到底哪里出了错。

一旦你说服了一个编译器工程师实现这个功能,并让它在你的编译器中实现,你还需要回到你的库开发者那里说,“嘿,编译器支持这个了,请开始使用它。” 这样我们才能为你们维护的库获得更好的文档和更好的诊断信息。我们在这里真正强调的是,如果你不要求改变,情况就不会变得更好

作为实现者,我们如何帮助用户?

我认为这又回到了我们整个演讲中提出的所有关键点:

  1. 了解你的受众。思考你为谁编写文档,并为那些人量身定做。

  2. 从多个维度衡量你的文档质量。我们给了你一些可以考虑的维度。也许你有自己的维度,也许有些我们不知道的关于你受众的事情而你知道。然后你可以用这些作为指导你文档质量的标准。

  3. 使用可用的工具来最大化你的文档质量。我们不会告诉你用什么工具。我们不希望你教条主义。我们希望你考虑你有什么可用的。

  4. 概念可以被用作文档工具。我想我们向你展示了可以这样做的好的方式……

  5. ……但它们也可能被滥用。不要只是把概念当作大棒来用,因为编译器也理解它们。要考虑你的受众。

我想如果你遵循所有这些关键点,你的文档会好得多。

非常感谢。

问答环节

问:如何在你认为的“正确文档化”和“过度文档化”之间找到平衡?

答 (Sy Brand):这是一个非常好的问题。我认为这很大程度上取决于思考你的受众。当然,对于像 C++ 这样的东西,很多时候你的受众是双重的:可能是对你的库不熟悉的新手,也可能是高级用户。所以要思考如何以一种对这两类人都有效的方式呈现信息。比如把示例放在顶部,或者把高级信息隐藏在一个“展开”按钮后面之类的。关键是思考你的受众,思考他们需要什么,以及你受众中的不同群体需要什么。

问:关于让 cppreference 改变风格的问题,你已经回答了,对吧?

答 (Christopher Di Bella):是的,基本就是参与进去,发起讨论。

(结束语和联系方式)

我们会在 Gather Town 空间里转转,如果你有任何进一步的问题,或者你可以在 Twitter 上找到我们俩。我是 @TartanLlama,Chris 是 @cjdb_ns。Marshmallow 也在 Twitter 上很活跃。或者你可以在 Discord 上联系我们。非常感谢你们的参与,希望你们享受接下来的会议。再见。

谢谢。