Knowledge center

带你读《C++语言导学》之三:模 块 化-阿里云开发者社区(en)

2019-11-07 00:00:00 mimukeji

点击查看第一章
点击查看第二章

第3章

A Tour of C++, Second Edition

模 块 化

我打断你的时候你不许打断我。—温斯顿·丘吉尔

3.1 引言

一个C++程序包含许多独立开发的部分,例如函数(参见1.2.1节)、用户自定义类型(参见第2章)、类层次(参见4.5节)和模板(参见第6章)等。其管理的关键就是清晰地定义这些组成部分之间的交互。第一步也是最重要的一步是将每个部分的接口和实现分离开来。在语言层面,C++使用声明来表达接口。声明(declaration)指明了使用一个函数或一个类型所需要的东西。例如:
image.png
这里的关键点是函数体,即函数的定义(definition)是位于“别处”的。对本例,我们可能也想让Vector的表示位于“别处”,不过稍后将再对此进行介绍(抽象类型,参见4.3节)。sqrt()的定义如下所示:
image.png
对于Vector来说,我们需要定义全部三个成员函数:
image.png
我们必须定义Vector的函数,而不必定义sqrt(),因为它是标准库的一部分。但是这没什么本质区别:库不过就是一些“我们碰巧用到的其他代码”,它也是用我们所使用的语言设施所编写的。
一个实体(例如函数)可以有很多声明,但只能有一个定义。

3.2 分别编译

C++支持一种名为分别编译的概念,用户代码只能看见所用类型和函数的声明。这些类型和函数的定义则放置在分离的源文件里,并被分别编译。这种机制有助于将一个程序组织成一组半独立的代码片段。这种分离可用来最小化编译时间,并严格强制程序中逻辑独立的部分分离开来(从而最小化发生错误的可能)。库通常是一组分别编译的代码片段(如函数)的集合。
通常,我们将说明模块接口的声明放置在一个文件中,文件名指示出预期用途。例如:
image.png
这段声明被置于文件Vector.h中,我们称这种文件为头文件(header file),用户将其包含(include)到自己的程序中以便访问接口。例如:
image.png
image.png
为了帮助编译器确保一致性,负责提供Vector实现部分的.cpp文件同样应该包含提供接口的.h文件:
image.png
user.cpp和Vector.cpp中的代码共享Vector.h中提供的接口信息,但这两个文件是相互独立的,可以被分别编译。这几个程序片段可图示如下。
image.png
严格来说,使用分别编译并不是一个语言问题,而是关于“如何以最佳方式利用特定语言实现”的问题。但不管怎么说,其实际意义非常重要。程序组织的最佳方式就是将程序看作依赖关系定义良好的一组模块,逻辑上通过语言特性表达模块化,物理上通过文件利用模块化实现高效的分别编译。
一个单独编译的.cpp文件(包括它使用#include包含的.h文件)称为一个编译单元(translation unit)。一个程序可以包含数以千计的编译单元。

3.3 模块(C++20)

使用#include是一种古老的、易出错的且代价相当高的程序模块化组织方式。如果你在101个编译单元中使用#include header.h,编译器将会处理header.h的文本101次。如果你在header2.h之前使用#include header1.h,则header1.h中的声明和宏可能影响header2.h中代码的含义。相反,如果你在header1.h之前使用#include header2.h,则header2.h可能影响header1.h中的代码。显然,这不是一种理想的方式,实际上,自1972年这种机制被引入C语言之后,它就一直是额外代价和错误的主要来源。
我们的最终目的是想找到一种在C++中表达物理模块的更好方法。语言特性module尚未纳入ISO C++标准,但已是ISO技术规范[ModulesTS]。已有C++实现提供了module特性,因此我在这里冒一点风险推荐这个特性,虽然其细节可能发生改变,而且距离每个人都能使用它编写代码还有些时日。旧代码,即使用#include的代码,还会“生存”非常长的时间,因为代码更新代价很高且非常耗时。
我们考虑使用module表达3.2节中的Vector和use()例子:
image.png
这段代码定义了一个名为Vector的模块,它导出类Vector及其所有成员函数和非成员函数size()。
我们使用这个module的方式是在需要它的地方导入(import)它。例如:
image.png
image.png
我本可以对标准库数学函数也采用import,但我使用了老式的#include,借此展示新旧风格是可以混合的。在渐进地将#include旧代码更新为import新式代码的过程中,这种混合方式是必要的。
头文件和模块的差异不仅是语法上的。

  • 一个模块只会编译一遍(而不是在使用它的每个编译单元中都编译一遍)。
  • 两个模块可以按任意顺序导入(import)而不会改变它们的含义。
  • 如果你将一些东西导入一个模块中,则模块的使用者不会隐式获得这些东西的访问权(但也不会被它们所困扰):import无传递性。

这些差异对可维护性和编译时性能的影响是惊人的。

3.4 名字空间

除了函数(参见1.3节)、类(参见2.3节)和枚举(参见2.5节)之外,C++还提供了一种称为名字空间(namespace)的机制,用来表达某些声明属于一个整体以及它们的名字不会与其他名字冲突。例如,我希望利用自己定义的复数类型(参见4.2.1节、14.4节)进行实验:
image.png
通过将我的代码放在名字空间My_code中,就可以确保我的名字不会与名字空间std(参见3.4节)中的标准库名字冲突。这种预防措施是明智的,因为标准库的确提供了complex算术运算(参见4.2.1节、14.4节)。
访问另一个名字空间中的名字,最简单的方法是用名字空间的名字对其进行限定(例如std::cout和My_code::main)。“真正的main()”定义在全局名字空间中,换句话说,它不属于任何自定义的名字空间、类或者函数。
如果反复对一个名字进行限定变得令人乏味、分散注意力,我们可以使用using声明将名字引入作用域中:
image.png
using声明令来自一个名字空间中的名字变得可用,就如同它声明在当前作用域中一样。我们使用using std::swap后,就像是已在my_code()中声明了swap一样。
为获取标准库名字空间中所有名字的访问权,我们可以使用using指示:
image.png
using指示的作用是将具名名字空间中未限定的名字变得在当前作用域中可访问。因此,对std使用using指示之后,我们直接使用cout就可以了,无须再写std::cout。使用using指示后,我们就失去了选择性地使用名字空间中名字的能力,因此必须小心使用这一特性,通常是用在一个库遍布于应用中时(如std)或是在转换一个未使用namespace的应用时。
名字空间主要用于组织较大规模的程序组件,例如库。名字空间简化了用单独开发的组件组合程序的过程。

3.5 错误处理

错误处理是一个大而复杂的主题,其内容和涉及面都远远超越了语言设施层面,而深入到了程序设计技术和工具的范畴。不过C++还是提供了一些对此有帮助的特性,其中最主要的一个工具就是类型系统。我们不应基于内置类型(如char、int和double)和语句(如if、while和for)来费力地构造应用程序,而是应构造适合我们应用的类型(如string、map和regex)和算法(如sort()、find_if()和draw_all())。这些高级构造简化了程序设计,减少了产生错误的可能(例如,你不太可能对一个对话框应用树遍历算法),同时也增加了编译器捕获错误的机会。大多数C++构造都致力于设计并实现优雅且高效的抽象(如用户自定义类型和使用这些自定义类型的算法)。这种抽象机制的一个效果就是运行时错误的捕获位置与错误处理的位置被分离开来。随着程序规模不断增大,特别是库的广泛使用,处理错误的标准变得愈加重要。在程序开发中,尽早地明确错误处理策略是一个好办法。

3.5.1 异常

让我们重新考虑Vector的例子。对2.3节中的向量,当我们试图访问某个越界的元素时,应该发生什么呢?

  • Vector的编写者并不知道使用者在面临这种情况时希望如何处理(通常情况下,Vector的编写者甚至不知道向量被用在何种程序中)。
  • Vector的使用者不能保证每次都检测到问题(如果他们能做到的话,越界访问也就不会发生了)。

假设越界访问是一种错误,我们希望能从中恢复,合理的解决方案是由Vector的实现者检测意图越界的访问并通知使用者,然后使用者可以采取适当的应对措施。例如,Vector::operator[]()能够检测到意图越界的访问,并抛出一个out_of_range异常:
image.png
throw将程序的控制权从某个直接或间接调用Vector::operator[]()的函数转移到out_of_range异常处理代码。为此,C++实现需能展开(unwind)函数调用栈以便返回调用者的上下文。换句话说,异常处理机制会退出一系列作用域和函数以便回到对处理这种异常表达出兴趣的某个调用者,一路上会按需要调用析构函数(参见4.2.2节)。例如:
image.png
如果希望处理某段代码的异常,应将其放在一个try块中。显然,对v[v.size()]的赋值操作将会出错。因此,程序进入到catch子句中,它提供了out_of_range类型错误的处理代码。out_of_range类型定义在标准库中(在中),事实上,它也被一些标准库容器访问函数使用。
我捕获异常时采用了引用方式以避免拷贝,我还使用了what()函数来打印在throw点放入异常中的错误信息。
异常处理机制的使用令错误处理变得更简单、更系统、更具可读性。为了达到这一目的,要注意不能过度使用try语句。我们将在4.2.2节中介绍令错误处理简单且系统的主要技术(称为资源请求即初始化(Resource Aquisition Is Initialization,RAII))。RAII背后的基本思想是,由构造函数获取类操作所需的资源,由析构函数释放所有资源,从而令资源释放得到保证并隐式执行。
我们可以将一个永远不会抛出异常的函数声明成noexcept。例如:
image.png
image.png
一旦所有的好计划都失败了,函数user()仍抛出异常,此时会调用std::terminate()立即终止当前程序的执行。

3.5.2 不变式

使用异常报告越界访问错误是一个典型的函数检查其实参的例子,因为基本假设,即所谓的前置条件(precondition)没有满足,函数拒绝执行。如果我们正式说明Vector的下标运算符,我们将定义类似于“索引必须在[0:size())范围内”的规则,而这正是在operator[]()中要检查的。符号[a:b)指定了一个半开区间,表示a是区间的一部分,而b不是。每当定义一个函数时,就应考虑它的前置条件是什么以及如何检验它(参见3.5.3节)。对大多数应用来说,检验简单的不变式是一个好主意,参见3.5.4节。
但是,operator[]()对Vector类型的对象进行操作,而且只在Vector的成员有“合理”的值时才有意义。特别是,我们说过“elem指向一个含有sz个double的数组”,但这只是注释中的说明而已。对于类来说,这样一条关于假设某事为真的声明称为类不变式(class invariant),简称为不变式(invariant)。建立类的不变式是构造函数的任务(从而成员函数可以依赖该不变式),成员函数的责任是确保当它们退出时不变式仍然成立。不幸的是,我们的Vector构造函数只履行了一部分职责。它正确地初始化了Vector成员,但是没有检验传入的实参是否有效。考虑如下情况:
image.png
这条语句很可能会引起混乱。
下面是一个更好的定义:
image.png
本书使用标准库异常length_error报告元素数目为非正数的错误,因为一些标准库操作也是用这个异常报告这种错误。如果new运算符找不到可分配的内存,那么就会抛出std::bad_alloc。可以编写如下代码:
image.png
image.png
你可以定义自己的异常类,并令它们将任意信息从异常检测点传递到异常处理点(参见3.5.1节)。
通常,当抛出异常后,函数就无法继续完成分配给它的任务了。于是,“处理”异常的含义是做一些简单的局部清理然后重新抛出异常。例如:
image.png
设计良好的代码中很少见到try块,你可以通过系统地使用RAII技术(参见4.2.2节、5.3节)来避免过度使用try块。
不变式的概念是设计类的核心,而前置条件在函数设计中也起到类似的作用。不变式

  • 帮助我们准确地理解想要什么。
  • 强制我们明确表达想要什么,这给我们更多的机会编写出正确的代码(在调试和测试之后)。

不变式的概念是C++中由构造函数(参见第4章)和析构函数(参见4.2.2节、13.2节)支撑的资源管理概念的基础。

3.5.3 错误处理替代

错误处理在现实世界的所有软件中都是一个主要问题,因此很自然地有很多解决方法。如果错误被检测出来后无法在函数内局部处理,函数就必须以某种方法与某个调用者沟通这个问题。抛出异常是C++解决此问题的最一般的方法。
在有的语言中,提供异常机制的目的是为返回值提供一种替代机制。但C++不是这样的语言:异常是用来报告错误、完成给定任务的。异常与构造函数和析构函数一起为错误处理和资源管理提供一个一致的框架(参见4.2.2节、5.3节)。当前的编译器都针对返回值进行了优化,使其比抛出一个相同的值作为异常高效得多。
对于错误不能局部处理的问题,抛出异常不是报告错误的唯一方法。函数可用如下方式指出它无法完成分配给它的任务:

  • 抛出一个异常。
  • 以某种方式返回一个值来指出错误。
  • 终止程序(通过调用terminate()、exit()或abort()这样的函数)。

在下列情况下,我们返回一个错误指示符(一个“错误码”):

  • 错误是常规的、预期的。例如,打开文件的请求失败就是很正常的(可能没有给定名字的文件或文件不能按请求的权限打开)。
  • 预计直接调用者能合理地处理错误。

在下列情况下我们抛出异常:

  • 错误很罕见,以致程序员很可能忘记检查它。例如,你最后一次检查printf()的返回值是什么时候?
  • 立即调用者无法处理错误。取而代之,错误必须层层回到最终调用者。例如,让一个应用中的所有函数都可靠地处理每个分配错误或网络故障是不可行的。
  • 在一个应用中,底层模块添加了新的错误类型,以致编写高层模块时不可能处理这种错误。例如,当修改一个旧的单线程应用令其能使用多线程,或使用放置在远端需要通过网络访问的资源时。
  • 错误代码没有合适的返回路径。例如,构造函数无法返回值给“调用者”检查。特别是,构造函数的调用是发生在构造多个局部变量时或是在一个复杂对象构造了一部分时,这样基于错误码的清理工作就会变得非常复杂。
  • 由于在返回值的同时还要返回错误指示符,函数的返回路径变得更为复杂或代价更高(例如使用pair,参见13.4.3节),这可能导致使用输出参数、非局部错误状态指示符或其他变通方法。
  • 错误必须沿着调用链传递到“最终调用者”。反复检查错误码会很乏味、低效且易出错。
  • 错误恢复依赖于多个函数调用的结果,导致需要维护调用和复杂控制结构间的局部状态。
  • 发现错误的函数是一个回调函数(函数参数),因此立即调用者甚至可能不知道调用了哪个函数。
  • 错误处理需要执行某个“撤销动作”。

在如下情况下,我们终止程序:

  • 错误是无法恢复的类型。例如,对很多(但不是所有)系统,没有合理的方法从内存耗尽错误中恢复。
  • 在检测到一个非平凡错误时,系统的错误处理基于重启一个线程、一个进程或一台计算机。

确保程序终止的一种方法是向函数添加noexcept(),从而在函数实现的任何地方抛出异常都会进入terminate()。注意,有的应用不能接受无条件终止,这就需要使用替代方法。
不幸的是,上述条件并不总是逻辑上互斥的,也不总是容易应用。程序的规模和复杂度都会对此有影响。有时,随着应用的进化,各种因素间的权衡会发生改变,这时就需要程序员的经验了。如果存疑,你应该优先选择异常机制,因为其伸缩性更好,也不需要外部工具来检查是否所有的错误都被处理了。
不要认为所有的错误码或所有的异常都是糟糕的,它们都有清晰的用途。而且,不要相信异常处理很缓慢的传言,它通常比正确处理复杂的或罕见的错误条件以及重复检验错误码要更快。
对于使用异常实现简单、高效的错误处理,RAII(参见4.2.2节、5.3节)是很必要的。充斥着try块的代码通常反映了基于错误码构思的错误处理策略最糟糕的那一面。

3.5.4 合约

我们经常需要为不变式、前置条件等编写可选的运行时检验,目前对此还没有通用的、标准的方法。为此,已为C++20提出了一种合约机制[Garcia,2016] [Garcia,2018]。一些用户想依赖检验来保证程序的正确性—在调试时进行全面的运行时检验,而随后部署的代码包含尽量少的检验,合约的目标是为此提供支持。一些组织依赖系统、全面的检验,在其高性能应用中这一需求就很常见。
到目前为止,我们还不得不依赖特别的机制。例如,我们可以使用命令行宏来控制运行时检验:
image.png
标准库提供了调试宏assert(),以主张在运行时某个条件必须成立。例如:
image.png
在“调试”模式下,如果assert()的条件失败,程序会终止。如果不在调试模式下,assert()则不会被检查。这相当粗糙,也很不灵活,但通常已经足够了。

3.5.5 静态断言

异常负责报告运行时发现的错误。如果错误能在编译时发现,当然更好。这是大多数类型系统以及自定义类型接口说明设施的主要目的。不过,我们也能对大多数编译时可知的性质做一些简单检查,并以编译器错误消息的形式报告所发现的问题。例如:
image.png
如果4<=sizeof(int)不成立,即当前系统中一个int占据的空间不足4字节,则输出integers are too small信息。将这种表达我们的期望的机制称为断言(assertion)。
static_assert机制能用于任何可以表示为常量表达式(参见1.6节)的东西。例如:
image.png
一般而言,static_assert(A,S)的作用是当A不为true时,将S作为一条编译器错误信息输出。如果你不希望打印特定消息,可以忽略S,编译器会提供一条默认消息:
image.png
默认消息通常是static_assert所在位置加上表示断言谓词的字符。
static_assert最重要的用途是在泛型编程中为类型参数设置断言(参见7.2节、13.9节)。

3.6 函数参数和返回值

函数调用是从程序的一个部分向另一个部分传递信息的主要方式,也是推荐方式。执行任务所需的信息作为参数传递给函数,生成的结果作为返回值传回。例如:
image.png
函数间也存在其他传递信息的路径,例如全局变量(参见1.5节)、指针和引用参数(参见3.6.1节),以及类对象中的共享状态(参见第4章)。全局变量是众所周知的错误之源,我们强烈建议不要使用它,而状态通常只应在共同实现了一个良好定义的抽象的函数间共享(例如,类的成员函数,参见2.3节)。
了解了函数传递信息的重要性,就不会对存在多种传递方式感到惊讶了。其中的重点是:

  • 对象是拷贝的还是共享的?
  • 如果共享对象,它可变吗?
  • 对象可以移动从而留下一个“空对象”吗?(参见5.2.2节)

参数传递和返回值的默认行为是“拷贝”(参见1.9节),但某些拷贝可隐式优化为移动。
在sum()例子中,得到的int被拷贝出sum()而将可能非常大的vector拷贝进sum()会很低效且无意义,因此参数是以引用方式传递的(用&指出,参见1.7节)。
sum()没有理由修改其实参。这种不可变性是通过将vector参数声明为const实现的(参见1.6节),因此vector是以const引用方式传递的。

3.6.1 参数传递

首先考虑如何将值传入函数。默认是拷贝方式(“传值”),如果我们希望在调用者的环境中引用一个对象,则可采用引用方式(“传引用”)。例如:
image.png
image.png
当关注性能时,我们通常采用传值方式传递小对象,用传引用方式传递大对象。这里“小”的含义是指“拷贝代价确实很低的东西”。“小”的准确含义依赖于机器架构,但“两三个指针大小或更小”是一条很好的经验法则。
如果基于性能原因想采用传引用方式,但又不希望修改实参,则可采用传const引用的方式,就像sum()例子中那样。这是目前为止普通程序代码中最常见的情况:这种参数传递方式又快又不易出错。
函数参数具有默认值是很常见的,即一个值被认为是首选的或是最常见的。我们可以采用默认函数参数(default function argument)来指定这样一个默认值。例如:
image.png
它是重载的一种替代,符号上更为简单:
image.png

3.6.2 返回值

一旦计算出了结果,就需要将其从函数传递回调用者。再次强调,返回值的默认方式是拷贝,对小对象这是很理想的。我们仅在希望授权调用者访问函数的非局部对象时才以“传引用”方式返回值。例如:
image.png
Vector的第i个元素的存在与下标运算符的调用是无关的,因此我们可以返回它的引用。
另一方面,在函数返回时局部变量就消失了,因此我们不应该返回局部变量的指针或引用:
image.png
幸运的是,所有主要的C++编译器都能捕获bad()中的明显错误。
返回一个“小”类型的引用或值都很高效,但如何将大量信息从函数中传递出来呢?考虑下面的代码:
image.png
一个Matrix可能非常大,从而在现代硬件上做拷贝的代价很高。因此不进行拷贝,而是为Matrix设计一个移动构造函数(参见5.2.2节),将Matrix移出operator+()的代价是很低的。我们无须倒退到使用手工内存管理:
image.png
不幸的是,通过返回指针来返回大对象的方式在旧代码中很常见,这是一些很难发现的错误的主要来源。不要编写这样的代码。注意,operator+()与add()一样高效,但远比其更容易定义、更容易使用、更不易出错。
如果一个函数不能执行我们要求它执行的任务,它可以抛出异常(参见3.5.1节)。这有助于避免代码中到处是“异常问题”的错误码检验。
一个函数的返回类型可以从其返回值推断出来。例如:
image.png
这很方便,特别是对泛型函数(函数模板,参见6.3.1节)和lambda(参见6.3.3节),但要小心使用它,因为推断类型不能提供一个稳定的接口:改变函数(或lambda)的实现就可能改变类型。

3.6.3 结构化绑定

一个函数只能返回一个值,但这个值可以是一个包含很多成员的类对象。这令我们可以高效地返回很多值。例如:
image.png
在本例中,我们用{s,i}构造Entry类型返回值。类似地,可以将一个Entry的成员“解包”到局部变量中:
image.png
auto [n,v]声明了两个局部变量n和v,它们的类型是从read_entry()的返回值推断出来的。这种为类对象的成员赋予局部名字的机制称为结构化绑定(structured binding)。
考虑另一个例子:
image.png
照例,我们用const和&装点auto。例如:
image.png
当我们将结构化绑定用于没有私有数据的类时,很容易看到绑定是如何进行的:定义的用于绑定的名字数目必须与类的非静态数据数目一致,且绑定时引入的每个名字为对应的成员命名。与显式使用组合对象的版本相比,代码质量没有什么差别,结构化绑定的使用只关乎如何更好地表达一个思想。
如果类是通过成员函数来访问的,结构化绑定也能处理。例如:
image.png
一个complex有两个成员,但其接口由访问函数组成,如real()和imag()。将一个complex映射到两个局部变量(如re和im)是可行的,也很高效,但完成这一目的的技术已经超出了本书的范围。

3.7 建议

[ 1 ] 区分声明(用作接口)和定义(用作实现);3.1节。
[ 2 ] 使用头文件描述接口、强调逻辑结构;3.2节;[CG: SF.3]。
[ 3 ] 使用#include将头文件包含到实现其函数的源文件中;3.2节;[CG: SF.5]。
[ 4 ] 在头文件中应避免定义非内联函数;3.2节;[CG: SF.2]。
[ 5 ] 优先选择module而非头文件(在支持module的地方);3.3节。
[ 6 ] 用名字空间表达逻辑结构;3.4节;[CG: SF.20]。
[ 7 ] 将using指示用于程序转换、基础库(如std)或局部作用域中;3.4节;[CG: SF.6] [CG: SF.7]。
[ 8 ] 不要在头文件中使用using指示;3.4节;[CG: SF.7]。
[ 9 ] 抛出一个异常来指出你无法完成分配的任务;3.5节;[CG: E.2]。
[10] 异常只用于错误处理;3.5.3节;[CG: E.3]。
[11] 预计直接调用者会处理错误时就使用错误码;3.5.3节。
[12] 如果通过很多函数调用预计错误会向上传递,则抛出异常;3.5.3节。
[13] 如果对使用异常还是错误码存疑,优先选择异常;3.5.3节。
[14] 在设计早期就规划好错误处理策略;3.5节;[CG: E.12]。
[15] 用专门设计的用户自定义类型(而非内置类型)作为异常;3.5.1节。
[16] 不要试图在每个函数中捕获所有异常;3.5节;[CG: E.7]。
[17] 优先选择RAII而非显式的try块;3.5.1节、3.5.2节;[CG: E.6]。
[18] 如果你的函数不抛出异常,那么将其声明成noexcept;3.5节;[CG: E.12]。
[19] 令构造函数建立不变式,如果不成功,就抛出异常;3.5.2节;[CG: E.5]。
[20] 围绕不变式设计你的错误处理策略;3.5.2节;[CG: E.4]。
[21] 能在编译时检查的问题通常最好在编译时检查;3.5.5节;[CG: P.4] [CG: P.5]。
[22] 采用传值方式传递“小”值,采用传引用方式传递“大”值;3.6.1节;[CG: F.16]。
[23] 优先选择传const引用方式而非传普通引用方式;_module.arguments_;[CG: F.17]。
[24] 用函数返回值方式(而非输出参数)传回结果;3.6.2节;[CG: F.20] [CG: F.21]。
[25] 不要过度使用返回类型推断;3.6.2节。
[26] 不要过度使用结构化绑定,使用命名返回类型在程序文本角度下通常更为清晰;

(en)

阿里云优惠新机+优惠券

本文转载自网络,如有侵权,请联系我们删除。

Home

About

product

success

news

form

bbs

contact

工单(en)

阿里云报价咨询(en)