访谈 | C++之父Bjarne Stroustrup: 简单的表述方式才是最优的方案
Bjarne Stroustrup(本贾尼·斯特劳斯特卢普)
1982年,贝尔实验室(美国AT&T公司)的Bjarne Stroustrup博士在c语言的基础上引入并扩充了面向对象的概念,发明了新的程序语言C++。之所以被命名为C++,是为了表达该语言与c语言的渊源关系。Bjarne Stroustrup博士因此被尊称为“C++语言之父”。
之后,面向对象的编程思想开始席卷整个开发领域,标准模板库(STL)和微软的VC++平台推波助澜,C++开始流行起来。可以说,C++对整个软件开发及IT业的贡献,不言而喻。
C++仍在它擅长的领域发挥着不可或缺的作用。作为C++之父,Bjarne Stroustrup也一直致力于C++标准的改进和推广,其著作《C++编程语言》《C++的设计和演化》和《C++加注参考手册》等已成为C++学习的经典读物。
◆ ◆ ◆ ◆
访谈内容
除了作为编程技术大师为人熟知以外,Bjarne还有很多至理名言被大家广泛引用。在观看之前的访谈时,我也被您发人深思和辩证的思维所折服。如何做到将自然语言和编程语言运用如此得体的高度呢?
我想要直接而简洁地表达观点,虽然并不总能成功,但这值得一试。记住,当你写代码的时候,并不仅仅是给编译器看的。相反,代码的“消费者”包括所有阅读和维护代码的人。如果你的代码丑陋不堪、难以理解,它将无法运行甚至造成巨大的维护问题。因此,无论是代码还是“普通文本”,其目的都是清晰地表达观点,帮助其他人理解这些想法。写作是一种缕清思路的方法——对自己和他人来讲,都是。
◆ ◆ ◆ ◆
我记得,您曾经讨论过人们对C++的误解(要理解C++,首先要学习C语言;C++是一种面向对象的语言;可靠的软件需要垃圾回收机制;为了提高效率,必须编写低级代码;C++只适合大型复杂的程序)现在,他们的偏见有所改观吗?
一些人了解了,还有很多人没有。这些谬见普遍充斥于网络上、文章和教科书里,通常被理所当然地接受——即便没有证据支撑仍然被当作事实陈述。所以,很难进行反驳。相信这些谬见的人并不认为自己对C++持有偏见,他们认为自己是进步的,甚至因为这些观点变得优越。
我想借此机会,鼓励大家花点儿时间(重新)审视下自己的观点,同时简单陈述下我自己的一些观点。如果想从技术角度了解详细的论证过程,请参考我的相关论文和书籍。
要理解C++,必须首先学习C语言。不是的,如果你本身已经是一名程序员了,完全可以直接进入类设计和使用各种库。如果你刚开始接触某种语言,能够处理低级的编程问题,可以依赖C++的强类型检查和各种库更容易、更快速地掌握基础知识。编程新手在使用低级工具(指针、数组、malloc()或free()、casts、宏)的时候,没有理由马上就了解它们存在的问题。复制或比较C语言里的字符串对于编程新手来说是痛苦、单调乏味的。
当然了,不了解指针、数组、*存储管理(动态内存管理、堆)等方面的知识,就不能在C++上有所建树,但可以之后再学习,等掌握了编程常识和C++基础以后再学习。基于这样的想法,我为大学新生(一年级学生)设计了一套课程并编写了相应的课本:http://www.stroustrup.com/programming.html 。效果很好。
C++是一种面向对象的语言。不是的,对于大多数包含继承性的传统意义上的OO来说,不是这样的。C++确实支持面相对象编程技术,也相当优秀,但这并不是C++的全部。现代C++,包括大部分的ISO C++标准库,更多地不再遵循这种模式。C++开始使用简单的具体类型和独立函数,并且仅在应用程序域采用分层架构、需要运行时调用的时候才使用运行时多态性。大多数受欢迎的C++应用程序使用了很多技术,并不仅仅是传统的面向对象技术,有时候甚至根本没有采用面向对象技术。
可靠的软件需要垃圾回收机制。不是的,GC有时会阻碍可靠性的达成。GC并不能消除所有的内存泄漏,不能解决非内存资源的管理问题。泄漏套接口、文件句柄、线程和锁,可能比内存泄漏更容易让系统停止。支持可靠性的最好办法是,找到应对资源管理和错误处理的方法,比如C++提供的RAII (Resource Aquisition Is Initialization, 资源获取即初始化)。我目前正在研究这种方法,为资源安全和类型安全的C++提供一套全面的系统:Http://www.stroustrup.com/resource-model.pdf 。核心观点是保证没有泄漏,使垃圾收集器没有必要存在。在不影响程序员用代码简单、直接地表达想法的前提下,保证没有泄漏确实很难,但不是没有可能。
为了提高效率,必须编写低级代码。不是的,现代C++十分擅长低级优化和不同抽象层次间的优化,多少数量的代码都无法跟这种能力相比,特别是现代架构具有深度缓存层次结构和配有大幅度指令调序的优化器的情况下。从更高的层面说,人类无法通过直接使用线程和锁,得到最优化的结果,所以我们需要更高级的模型和算法,获得正确性、可靠性、可预测性和原始性能。当关于机器、数据或算法的一些观点被证明是毫无根据的时候,摆弄bit、byte和指针这些基础会变得可悲。举个列子,看一下我和别人合著的这篇文章(http://www.stroustrup.com/improving_garcia_stroustrup_2015.pdf )。文章通过去掉精心设计的优化,提高了spec-mark程序的性能。最后生成的程序变得更精简、更清洁、易于维护、可扩展,而且没有副作用。关于零开销抽象(zero-headed abstraction)的问题,我已经谈了很多。最近,我还看到了很多负开销抽象(negative-headed abstraction)的示例:通过简化适当的抽象获得最优化程序。
C++只适合大型复杂的程序。不是的,除非你认为一两页代码的量就算是大型、复杂的项目。要知道,任何有影响的程序都需要用到一个或更多个库。这适用于每一种语言。不用任何库,单凭光秃秃的语言,对于编程人员来讲是痛苦的也是徒劳的。
在讨论C++或是(更糟地)下定结论时,随随便便地坚持某种谬见,不加思考,只能说明懒惰。之前,我也写了一篇澄清这些谬见的文章:www.stroustrup.com/Myths-final.pdf 。
◆ ◆ ◆ ◆
C++并非静止不前的。标准委员会很快就将宣布C++17的新增特征。您认为哪些特征是值得期待的?
C++17新增了很多小的改进,对于每一个程序员来说都值得期待,但不要指望特别重大或是颠覆性的改进出现。预计在2017标准发布后,这些新增特征随即可以在所有主要的编译器里应用。事实上,大多数C++17的特征已经能用了。
新增特征不一定对所有人有帮助,大多是为了特定群体的需要而完善C++或是标准库的。你可以在搜索引擎里输入C++找到新增特征的详细列表,不过,我会在这里简要谈几点我所喜欢的特征:
结构化绑定:使用C++17,我们可以打破结构,为结构成员命名。例如:
map<int,string>mymap; //...
auto[iter,success]=mymap.insert(value); if (success)f(*iter);
对于map<int,string>,insert()返回pair<mymap<int,string>::iterator,bool>,现在我们可以命名两个返回值并直接使用,而不用创建一个pair对象,再访问它的成员。
对于循环控制,这一点特别有用:
for(const auto&[key,value]:mymap)
cout<<key<<”->”<<value<<’\n’;
我们用std::variant 让union的显式使用变得冗余。现在,我们可以
variant<int,double>v; //可以是int或是double
v=12; auto i=get<int>(v); //i 变成了12
auto d=get<double>(v); //会抛出bad_variant_access异常
对于之前没有定义求值顺序的情况,现在,多数情况下是可以定义的。例如
count<<f(x)<<””<<g(y)<<’\n’;
可以保证输出g(y)的值之前先输出f(x)的值。在C++17之前,f(x)和g(y)是可以交错的,这容易产生bug和混乱。
到2020年,我们将看到进行了重大改进的C++20。例如,
概念——显著简化、更好指定的泛型编程
模块——更好的模块化、更快的编译
协同程序——更加简单、快速的生成器和pipelines
库——简单、更快、更灵活的网络
新版STL——更快、更简单、更灵活的算法和ranges
这并不是科幻小说里的幻想,很多特征已经开始在某些领域应用了。问题是,ISO C++标准委员会能否通过。
◆ ◆ ◆ ◆
是否可以用某个新增的特征为例,向我们展示一下该特征是如何符合C++的演化原则(直接硬件访问;零开销抽象;静态类型)的?
提高硬件访问能力和低级代码的性能,是一项需要付出长期努力的任务。有些努力是看得见的,有些不容易看得到。
我们一直在努力提高编译时的计算能力,constexpr是这方面的典范。使用constexpr,我们可以指定一个函数在编译时取值,如果用常量表达式作为参数的话。同样,我们也可以确保编译时就完成某项计算工作。
constexpr int isqrt(int n) //编译时取值为常量参数
{
int i=1;
while(i*i<n) ++i;
return i-(i*i!=n); } constexpr int s1=isqrt(9); //s1是3
int x; //不是常量
//…
constexpr int s2=isqrt(x); //编译时,出错
count<<weekday{jun/21/2016}<<’\n’; //星期二
static_assert(weekday{jun/21/2016}==tue);
Constexpr配合使用const可以有效地提高性能,减少代码大小,并提升代码、ROM中数据处理的能力。因为“你不能对常量创造某个竞争状态”,所以有助于并发系统。
另一个不太明显的例子是,C++17确保多数情况下的复制省略。它让我们可以从函数中方便地得到值。例如
T compute(S a)
{
return complicated_computation_yielding_a_T(a);
}
T t=compute(s);
这里没有副本!能让我们从指针和动态内存中解脱出来,因为在现代的硬件访问中,间接和动态内存越来越昂贵(相对地)。如果和之前讲到的结构化绑定结合使用,会更有趣
pair<T,T2>compute(S a,S2 b)
{
return{ comp1(a,b),comp2(a,b) };
}
auto[foo,bar]=compute(s,s2);
同样,这里不需要复制。
在过去的二十年里,模板一直被认为是零开销抽象的,得到了迅猛的发展。它被广泛复制于其它的编程语言中,但通常并不灵活,也不如C++模板运行时的效率。但是,模板基本上会提供编译时的duck typing,而不是基于检查接口的程序;它们会在之后的实例化阶段进行类型检查。因此,模板的迅猛发展导致了相当复杂的编程技术问题。我们需要让泛型代码更接近于非泛型代码,更容易编写,更易于编译器检查同时不影响或限制表达性。
Contexpr 函数的功能包括:不再需要模板就可以得到编译时计算的值。如果你只需要某个类型的值,函数就能很好地表达。使用contexpr,编译时函数就能像其它函数一样,类型检查也能像其它函数的一样(不同于宏技巧或是传统模板的元编程)。
“概念”是支持模板接口规范的语言特征。遗憾的是,它没能成为C++17的新增特征,但作为ISO 技术规范已经应用于GCC6.2了。概念可以解决模板的很多问题。考虑一下advance(),简化版的标准程序库函数,它允许迭代器向前移动n个元素。假如我们需要两个版本,一个用于列表之类的东西,每次移动一个元素、操作n次;一个可以直接移动n个元素:
template<Input_iterator Iter>
void advance(Iter p,int n){while (—n)++p;}
template<Random_access_iterator Iter>
void advance(Iter p,int n){p+=n;}
也就是说,如果参数是一个随机访问的迭代器,使用第二种快速的版本;否则,使用第一个慢版本。
void(vector<int>::iterator pv, list<string>::iterator pl)
{
advance(pv,17); //fast
advance(pl,17); //slow
}
这是优化后的快速方案,我用短短几分钟就能向新手解释清楚。它跟“传统模板编程”不同,在编写方式和检查方式上都不同。如果愿意,我甚至可以进一步简化adavance的定义:
void advance(Input_iterator p, int n){while(n--)++p;}
void advance(Random_access_iterator p, int n){p+=n;}
这完全符合我们谈论代码的方式,任何一个天真的程序员也会相当合理地这样认为。
最近,我写了一篇文章(www.stroustrup.com/good_concepts.pdf)详细解释了concepts的观点。
◆ ◆ ◆ ◆
某种程度上讲,C++对专家更友好,只有少数的专业人士才能很好地掌握C++。如何减少初学者的困难呢?
“只有少数的专业人士能够很好地掌握C++”夸大了C++的难度,因为确实有数以百万的程序员们用C++编写出了优秀的系统。但坦白说,很多C++代码并不符合专业质量的要求,我们还能做得更好。
C++让编程专家很容易编写出复杂、高性能、低资源消耗的代码,但不足以成为广大普通程序员喜爱的语言,它需要简化。
我努力说服ISO C++标准委员会的专家还有许多的编程教师,说明我们需要不断的努力,开发和讲授更简单的方式,不能仅仅专注于最优化和最聪明的技巧。通常情况下,简单的表述方式才是最优化的方案,“聪明”的技巧对于读者、维护人员、优化器来说可能是一种负担。在谈论用代码表达思想的时候,我大多会用“聪明”表示“太复杂”。最好把聪明用在分析问题和找寻根本办法上。
“用简单的方案解决简单的事情。”之前,C++98 标准模板库采用for语句来控制循环执行:
for(vector<int>::iterator p=v.begin();v!=v.end();++p)
cout<<*p<<’\n’;
在C++11中,我们使用range-for-statement :
for(auto x:v)
cout<<x<<’\n’;
意思是“输出 v 中的所有成员 x”。auto表示“让 x 具有初始化器的类型,在这里,也就是 v 的元素类型”。
语言特征和标准库中的组件并不能很好地处理复杂性问题。所以,我开始制定一些指导准则,帮助大家更好地使用C++。我的这一做法也得到了其他人的支持,目前我们正在一起开发名为“C++核心准则”的项目,以及工具支持的相关问题。你可以到麻省理工开源项目下找到相关准则:https://github.com/isocpp/CppCoreGuidelines 。指导准则试图帮助程序员识别那些次优、容易出错的表达方式,最终编写出可读性强、易于维护、简单高效、类型安全和资源安全的代码(http://www.stroustrup.com/resource-model.pdf)。这并不是狂妄的想法!
这并不仅仅是为编程专家设计的。无论是专家还是初学者,都应该了解运用工具支持检测问题的观点。工具支持的早期版本可以在Visual Studio,Clang tidy和其他地方找到。
事实上,我们制定的指导准则已经受到了很多中高级编程人员的欢迎,他们把指导准则当作阅读材料,学习如何更加高效地使用C++11和C++14。每一个准则都有基本原理的支持,同时提供了正反面的代码示例。
◆ ◆ ◆ ◆
Guidelines Support Library的未来发展规划是怎样的?未来是否会像标准模板库一样,由主要的编译器支持或是提供?
核心准则的目的是提供一种进一步利用C++的方式,可以用来回答“未来5年代码是什么样”的问题。利用C++11和C++14已经能够很好地编写代码了,但程序员个体忙于业务,没有时间来评价新的工具,所以指导准侧和工具支持就显得很有必要。
具体的支持有两种形式:
弥补ISO 标准库不足的GSL
帮助执行准则、提供准确性保证的静态分析工具
GSL很小(也就十几种类和函数),主要目的在于避免程序员直接使用C++当中最棘手、最不安全的部分。比如,C++里有一种not_null 类型,确保指针不是nullptr,span可以传递给函数(pointer, size) pair对象。
关于GSL在GCC、Clang和微软的实现,可以参见GitHub上麻省理工学院下的开源项目:https://github.com/Microsoft/GSL 。考虑到兼容问题,我们正在努力实现GSL的标准样式规范。
核心指导准则是ISO标准委员会部分成员和其他外部人士共同执行的项目,它不是标准委员的工作。虽然我们已经向标准大会申请并希望某些GSL进入标准库,但现在它们还是各自独立的。
◆ ◆ ◆ ◆
相比较其他程序员,高技能的程序员具有哪些品质?接触编程学习较早,更加刻苦努力……
保持好奇心,愿意终身学习下去;面对困难时,坚持不妥协;不止在编程方面,设计和电脑方面的基础知识也必须坚实;乐于和系统用户进行有效的沟通。
编程学习没有“最佳年龄”或“最晚年龄”之说。如果你没有在10岁、20岁或是30岁的时候开始接触编程,这并不会影响你成为伟大编程大师的可能。我20岁才开始编程!重要的不仅是成为一名编程人员,你还要对自己设计的程序有感觉,你要了解相关学科、领域的知识经验。我认识的一些优秀编程人员并不是计算机科学专业出身的:有学习数学的、工程的、历史的、化学的、生物的,甚至还有哲学的。我认为,真正重要的是,趁你还很年轻的时候,能够喜欢上某些学科,选择具有挑战性和感兴趣的工作并养成良好的习惯。
我并不认为一味地刻苦努力,所有成绩拿A就是正确的方法。许多优秀的程序员是非常全面的人才,遗憾的是,并不是所有的。
◆ ◆ ◆ ◆
面对那些坚持“我不想知道如何弹钢琴,只想知道如何像霍洛维茨一样演奏”,急于寻求成功秘方的人,您的建议是?
霍洛维茨一生都在练习钢琴演奏;如果你也想成为编程界的霍洛维茨,就要决心用一辈子的时间去练习和学习。记住,“台上一分钟,台下十年功。”霍洛维茨第一次公开演出之前,花了很多年的时间来练习。应该有15年的时间都在练习。
要成为一名优秀的编程人员,你不需要是世界级的天才,也不用15年的编程学习,就可以开发出实际的应用程序。但我建议你,在把自己的编程成果展示给别人看之前,需要花时间认真地学习和练习编程。
我确信,霍洛维茨是从手指练习开始的,选择专门为初学者编写或是简单的曲目练习。他没有一开始就选择李斯特的《匈牙利狂想曲》,没有人上来就选择最难的曲目。我猜想,很少有人在缺乏坚实基础知识的情况下能达到高水平的成就。可以奔跑,但要在学会中低水平的技能(走路)之后。
参考:https://mp.weixin.qq.com/s/i8nTAVXzCquEO1ph6j_cHg