1 什么是指针
本文所谓的指针(pointer) ,是指C和C++等语言中的内建的语言特性。
在不一样范畴中指针这个概念有所不一样。在体系结构规范中,指针指称特定的整数字节地址或者两个地址的差(地址偏移量),是整数数值;而在C和C++中,做为核心语言特性支持的指针是一类类型的统称。这两种彻底不一样的概念常常被混淆,形成一些稀里糊涂的问题(和数组混在一块儿的时候尤甚)。除非另行说明,本文老是指后者,并不对此进一步展开论述。
C和C++中,指针(右)值是具备指针类型的(右)值。指针值有时也会被和指针混淆,但在健全的理解下一般能消歧义,所以问题不大(数组也有相似的状况,但涉及转换,问题相对严重)。为清晰起见,在这里不会不加区分地使用。
注意,C++的成员指针(pointer-to-member)明确地不是指针。尽管它的数值表示在一些状况下可能被实现为地址的偏移量,但语言中并不存在这种保证,实际也一般不那么简单。重复:成员指针不是这里讨论的指针。
此外,C++中,除了做为语言特性支持的内建(builtin) 指针,也有所谓的智能指针(smart pointer) 。后者在概念上也被 ISO C++11 以来的标准库正式支持。这里讨论的指针不包括这些智能指针,尽管后者和主题相关,而且会在下文重点讨论。
2 什么是设计
这里讨论的设计,是指语言的设计,也就是语言规则的做者决定语言特性中应该存在什么和不存在什么的决策之下的抽象结果。
用户如何使用指针即语用问题是和本文主题相关的问题,会一并讨论,但和这里的设计是两个不一样的话题。
3 什么是糟糕
糟糕是一个形容词。
形容设计的糟糕从两个递进的视点得出:对照设计要解决的问题,即需求;对照同类解决方案,即语言中的其它特性或应用领域有交集的其它程序设计语言中的特性。
通俗地,糟糕以“很差用”和“并不是不得很差用”来表现。
注意由于语言规则之间的相互做用,是否“好用”或者说要解决的问题,须结合使用场景下的其它问题一并讨论:一项特性即使能很好地解决某些问题,但若几乎老是引发其它难以回避的问题,那么至少是“不够好用”的;而要形成的问题麻烦到必定程度时就显然“很差用”了。算法
4 指针有什么用
在说明很差用以前,首先须要了解有什么用。
这是一个发散的语用问题,但大多数用法都很浅显,清楚语言规则就并不难概括。
4.1 指针和地址
C/C++的指针值和体系结构中的所说的指针(地址或地址偏移量)的基本做用相似,它用来指示数据存储的位置。
以体系结构的接口实现C/C++,能够轻易保证相同类型的指针值到地址的映射是单射,即相同指针类型的指针值的不一样的数值表示能够老是找到不一样的地址对应,这样就能够在整数算术和关系操做的基础上毫无额外代价地定义指针算术和关系操做;而指针上的操做符*抽象的正是间接寻址操做。这就是一些用户口中的所谓“接近底层”。这种简单直接实现的最大好处就是容易以很是小的代价生成针对特定体系结构的代码。
一个须要注意的关键不一样点在于,C/C++做为强类型语言(这里的用法也比较乱,指的是本来意义——默认具备静态类型检查),其中的值(value) 脱离类型讨论并无意义,指针值也不例外。对象指针能够进行算术操做,但和整数地址算术的含义并不相同,这受到具体指针类型的影响——例如,T*和整数的+操做和sizeof(T)相关;而函数指针并无相似的意义。此外,须要不一样间接操做层次的值如T*和T**也可被明确地静态地区分,光靠地址并不能作到这点。
然而,由于体系结构(硬件)实现的惯例,这个差别在每每能被利用(典型地,基址变址寻址),生成相对高效的代码。这是语言中保留指针算术的用途之一。另外一方面,把地址相关的整数数值明确和通常的整数值区分,也明确的目的,使静态检查非预期的混用成为可能,有限地提供了类型安全性(例如,指针和指针不能相加)。
经过两个地址,或一个地址和表示字节大小的一个非负整数就能够标识出地址空间的区间范围。把地址替换为对象指针、字节大小替换为长度(指针值指向的连续元素的个数)同时限制取得指针的手段,能保留这种标记连续存储区域的功效,同时提供必定的可移植性。这种连续的存储在类型系统上被抽象为数组。不过应当注意,在可移植的要求下,实际上指针的语义依赖于数组。彻底绕过数组的存在任意地构造一个指针值不能保证指向有效的对象或函数,进行间接操做基本上总会致使未定义行为。
另外一个关键不一样是空指针值(null pointer value) 并不保证有特定的地址对应,见下文。
4.2 存储资源管理
由于一个对象指针和长度能够用于表示连续的内存,而对象(排除VLA)的大小能在翻译时静态肯定,因此在已知大小的存储区域能够用一个对象指针值直接表示。
ISO C标准库的malloc和calloc以及ISO C++标准库的::operator new和::operator new[]等的返回值是典型的实例。
这里大小是由存储分配另外保存,这样释放时仍然只须要传递一个指针值便可。ISO C++14提供了sized deallocation,不过这并不是强制。
4.3 参数传递
由于从分配函数中取得的指针表示的存储并不会如自动变量同样会被自动回收,同时指针有间接操做,而指针值做为对象类型的值能够做为参数传递,所以传递指向对象的指针值配合间接操做就能够模拟传递对象的引用。
4.4 基于存储的迭代
由于对象指针能表示存储位置,连续存储的布局由存储模型(以及基于数组的语义)规则限定,适当类型的指针值进行算术操做能够双向顺序迭代乃至随机访问连续存储的对象。
4.5 空指针值
指针类型是可空类型(nullable) 类型,约定特殊的空指针值表示不指向任何对象或函数,但能够进行有限的比较。
可空类型很容易用来表示可选(optional) 值:约定空指针表示值不存在,非空指针指向的对象或函数即存在的可选值。
空指针值还能够表示哨位(sentinal) 即迭代终止的标识。相对于具体存储区间结束的指针相比,空指针值是通用的,并不须要根据特定的区间使用不一样的值。
注意空指针值的存储表示不必定是整数零值(这再次体现了人为预选的数值和地址的无关性),尽管使用零值通常能有更好的初始化性能。数组
5 指针为何很差用
既然标题已经肯定了指针设计的糟糕,那么在“很差用”上天然有充分的理由。
总结就是,指针看上去能干不少事,但没同样事是彻底干好的,还有的事(好比声明语法)甚至在通常意义上就特别差。
讽刺的是,第一个大规模使用这种指针的C语言做为UNIX系统的实现却彻底违反了UNIX程序鼓吹的模块化设计哲学:只作一件事,而且把事作好。
为何“程序”应当遵照的原则,分解到实现语言的特性的层次上,就能够罔顾设计原则乃至表现得相反了呢?难道这里不更应该体现接口的可组合性吗?回味无穷。
5.1 使用的意图
首要的原罪就是指针能干太多事了,致使若是只须要其中的某些功能子集(几乎全部状况都是这样,实际上也不可能全用上,见下文)就不容易看清楚代码在作什么,也就是任何“正常”的使用与使用其它替代实现手段相比,都很容易明显损害代码的可读性。
要避免这点,要么放弃使用指针而使用其它替代,要么就不得不以文档(包括注释)等形式来把这些接口规格中大多没必要出现的琐碎细节约定清楚。后者很容易显著增大实现和维护的工做量。
5.2 易错
由于意图不明的关系,使用指针的代码比使用其它更清晰的替代的代码更有机会错误,而指针自己的静态类型检查对此心有余而力不足。
最显著和严重的错误多是对于存储资源管理的错误。
注意C/C++语言要求去配函数的指针值参数若非空,则必须和适当的分配函数的返回(指针)值相等且不能以相同的值调用去配函数超过一次,不然程序行为未定义。
由于指针值并不保证翻译时肯定,静态检查对此类误用效果颇有限,要想安全使用且不泄露资源,用户必须清楚使用的指针是否能够被释放,而后准确保证从分配函数获得的非空指针值刚好做为参数调用正确的对应的去配函数一次——这里是否能够释放的全部权(ownership) 信息并无编码在指针的类型之中。
注意,单看一个指针值,有或者没有全部权是肯定的,不存在第三种情况。鉴于这两种情况互斥,所以一个指针值不可能同时是表示存在全部权的资源指针和表示不存在全部权的资源视图/观察者指针。这也就是上文说“不可能全用上”的缘由。
然而事实是明确持有一个有全部权的指针,须要释放时,光看指针根本无法知道该使用哪一个去配函数……更有甚者,其实光从指针上根本就看不出有没有全部权。
若是一个返回指针值的函数不幸没有文档描述清楚用户该如何处理资源释放问题,就面临了两难的风险:调用错误的去配函数或重复释放致使未定义行为,或者放置无论而至少产生泄漏的后果。
可能就是由于这样,WG14( C 标准委员会)有一条不成文的规矩:返回指针的函数老是不带全部权——也就是用户不该该释放这里的资源。
然而就连 Austin Group (起草 POSIX 标准的做者)对此都并不买帐(更别提 GNU 等了),形成了接口设计上的冲突(详见 WG14/N1174 ),可见这条默许的规则在 C 用户的范畴内总体上行不通。
用户该何去何从?看脸……(没有接口文档的本身踩坑怎么死?看着办。)
5.3 没必要要的负担
这里最明显的例子是明明静态肯定在不须要空指针的状况下不得不判断指针值是否为空,给程序运行带来没必要要的开销。
所谓的“空指针”滥觞于C.A.R.Hoare在1960年代的ALGOL W语言的发明。2009年,Hoare在一个会议上为此道歉,缘由是空指针特性引起了不少程序设计中的错误和漏洞。
盲目省略空指针值检查的致使使用指针间接操做的值引发未定行为的错误威力并不比上面资源管理的错误来得小。所以一旦接口沾染了指针,事情就复杂了——最容易的修复就是放弃使用指针这样的可空类型。
5.4 语法噪音
上面说的都是语义直接相关的语用困难。
事实上,即使不考虑语义问题,经验代表光是 C/C++ 的指针语法(严格来讲不光是指针本身的问题,还有数组、 C++ 的引用和 C++/CLI 的句柄等,都属于此类)也至关反直觉了。大部分用户遇到嵌套的指针声明符甚至都不能一会儿看明白边界,更别说表示什么意思了。
对这个问题的主要变通是使用 typedef 。但未必每一个接口都会老实用——好比 ISO C 的 signal 函数就没有用。因此遇到了用户仍是要硬着头皮看。另外还可能有同时有使用 typedef 和不使用 typedef 名称并存然而二者等价的局面,此时用户就得当人肉编译器自行验证 typedef 和复杂声明符的等价性了……
而现代的编译器也没能利用这样的语法带来简化。
鉴于这种看起来精巧实则无用的设计带来的困难,Bjarne Stroustrup 等在 C++ 尝试引入更直白的语法。可是,虽然 trailing-return-type syntax 是引入到 ISO C++11 里了,兼容 C 却不能排除旧的语法,结果就是对用户来讲存在两套不彻底兼容语法要学,编译器也得把两套语法都实现这样一个混乱局面……
5.5 语义噪音
一样由于意图不明的关系,要让不一样用法之间存在差别变得困难了。
举例来讲,C++不须要内建指针模拟对象引用传递参数,因此看到->和一元操做*(重载另说,但不抽风的重载不该该和这里的清晰性背道而驰)就能够大体肯定此处进行的是非平凡(模拟参数传递)的操做。
考虑到模拟引用参数也必然不须要空指针值,这样一来差距更明显。
5.6 抽象的无能
或许抽象能力的缺失才是最大的现实问题,由于关乎高级语言的本质目的,而并不是特定的个别需求。
一个例子是,迭代存储连续的序列用算术操做,为何一样是迭代,链表就不能相似的语法呢?
不过只是“很差用”的角度并不容易集中体现这一点,此处先略过。
5.7 互操做性
和体系结构的交互或许是指针惟一合适的领域了。不过,这依赖于实现的假设,所以操做起来并不那么有普适性。
即使平时鼓吹“硬件友好”“接近底层”,事实上 C 就不存在对地址空间的抽象,还得靠厂商或者 WG14/N1169 这类几乎名不见经传的扩展。
却是 C++ 标准库的分配器机制原本有要支持上面扩展的考虑不一样的指针,虽而后来都流行平坦地址空间而后这个需求就死得差很少了……
一个根本硬伤是,相同类型指针值到地址的映射是单射而不是满射——也就是任意一个地址即使在体系结构和实际机器的环境下容许,也有重重限制,根本不保证能用能自由操做的指针表示。
这样,关键时刻到底还得上体系结构相关的扩展乃至汇编和/或机器语言……(什么硬件友好接近底层,见鬼去吧(╯`□′)╯(┻━┻!)
5.8 理解的混乱
事实证实,指针自身的微妙规则以及和数组之间看似说不清道不明的关系给教材编写者以及初学者带来了极大麻烦。
无论是 Bjarne Stroustrup 鼓吹的 teachability 仍是通常用户期待的“易用性”,指针的语法和语义规则都是重灾区。
整体来看,这种的问题的根源来源于指针这项语言特性自身的设计——包括是否是真的适合做为核心语言特性这点。
5.9 谁来承担责任
好笑的是,缺陷这样明显的语言特性,一边在被各类集中地滥用和误用,一边被井底之蛙吹嘘为“ C 的灵魂”骗更多不知情者上当……
容忍这样的缺陷和制造混乱代码的做者一般是同一拨用户。对于不良语用致使的后果却每每由合做的理解更透彻的维护者承担,把本能够知足更多现实需求的时间花在给脑残粉的烂代码擦屁股的破事上。
这是有多不公平呢?安全
6 “指针”必须这样很差用吗/不用指针用啥
若是不限于内建指针,答案是否认的。
从指针几个有用和经常使用的使用惯例来看,搞清楚真实需求以后,很容易设计出更安全好用的机制。固然,得有足够的其它核心语言特性支持,类型系统羸弱的 C 只能靠边站。
对这里的缺陷修正得比较完全而又比较流行的例子主要就是 C++ ,同时 C++ 也保留了指针的操做,反而更有必要澄清何时不适合用指针。因此如下以 C++ 为例(涉及的主要特性,其它现代语言,即使没有指针也大多有对应)。
6.1 了解意图、避免常见错误和提高可读性
若须要间接操做表示资源,使用带全部权的智能指针。同时能够自动管理资源,避免资源泄漏。不加封装地使用内建指针意味着更混乱的代码路径,一般是糟糕的代码。
若须要间接操做表示不带全部权的资源视图,使用不带全部权的特定指针类型,如 WG21/N4282 提议的 observer_ptr 来帮助代表意图。(这里使用内建指针的问题相对比较小,在没有其它选择的偷懒状况下,使用内建指针相对来讲可以被容忍,由于带有全部权的指针已经被其它智能指针区分出去了。)
若须要传递引用,直接使用内建引用。在须要复制引用的场合,使用 std::ref 之类的包装。内建指针在此本质上毫无必要,而且没法使用大部分其它设施(只有 std::bind 等一些少数例外)。
若须要可空类型,使用 WG21/N4480 等规范的 optional 类型。(内建指针仍然是个能够忍耐的替代,但并不推荐。)
若须要迭代操做,使用迭代器(iterator) 。迭代器同时有更好的类型安全性、适应性和可扩展性。指针做为随机访问迭代器的特例是能够被使用的,但仍然应当当心行事。
经过划分典型应用场景,就基本解决了上面的最麻烦的一些问题。除了静态区分存在和不存在全部权相互矛盾以外,以上类型也是能够组合的,所以同时须要多种意图也不须要使用内建指针。
6.2 抽象能力和可扩展性
这集中体如今智能指针和迭代器与内建指针的对比之上。
内建指针的语义基本是被核心语言规则写死的,它并不能实现智能指针这样用户自定义资源全部权管理策略,以及迭代器这样的适配于不一样实现构造的序列上。由于过于特殊,能够说是至关地无能。高下立判。
经过迭代器类别(iterator category) 的抽象层次和 tag dispatch 这样基于重载(说穿了,一种模式匹配)的技巧,还能实现对不一样性质的序列静态自动选取最优算法。不知比指针高了哪里去了。
固然,内建指针和典型体系结构实现之间的能力仍旧没有被取代。但指针在真正底层(好比说,地址空间)的抽象仍然一直是个坑。并且这明显不是高级语言的本职工做。若是不是照顾兼容性,让厂商实现成扩展并用标准库包装,说不定还不会像如今那么混乱。
6.3 约束更强的设计
(现代) C++ 是强烈强调静态类型存在感的语言。这种设计有利有弊,但从实践效果来看,正确地使用可以发挥静态类型检查的优点,是当代软件工程实践的重要趋势之一。(静态类型固然有很是鸡肋的地方然而现实是大部分用户根本连边都碰不到……注意缺少元数据是 C++ 和标准化的锅,不是静态类型的锅。)
可是 C++ 限于历史包袱(兼容 C 、兼容如今各类代码),即使比 ISO C 勇于甩手扔包袱,也得考虑一下现实影响。在这个意义上,用户相对较少的小众语言以及新设计的语言就没有那么多顾虑,能将有目的的设计刻意发挥得更充分。
举两个稍微不怎么小众的例子。
一个是 Haskell 。应该说重点不纯粹是静态类型的问题,而是在类型系统的设计上使用了对静态分析友好的较为系统化的设计。(而并非像 C++ 那样一小坨一小坨地加特性,而这里最大坨的 Concept 被否了……)
固然这货主要用于开眼和拓展想象力,由于默认求值策略过于标新立异实际上不适合通用的需求,在 DSL 以上的实用仍是算了。
另外一个是 Rust 。嗯,设计的主要目标是取代 C++ ,应该还算是比较现实(?)的。在这里值得一提的是有很多设计把上面的策略整合到核心语言特性上去了而且有系统的理论支撑,好比 linear typing 是对 C++ 的 std::unique_ptr 强化。
姑且不论大杂烩的实用程度,这在科普上比较有意义。
6.4 复杂性谁来买单
有的用户可能会说,这么复杂,仍是用内建指针直接偷懒算了。
对此我只能表示呵呵。你真有自觉到彻底写清楚各个层次的接口文档代表语用?——注意,各个层次,包括如今当成实现细节而未来可能被接手的其余维护者当成内部接口使用的任意层次的“接口”。
若是:
(1)由于非自身缘由只能用 C 这等无能玩意儿的并且真作获得及说服了其它倒腾这坨代码的(若是有)也一样作到上面所说的自觉,或者——
(2)保证这坨代码不流入公众视野充实反面教材,同时实现者保证必要时时刻忏悔生产垃圾多出来的碳排放
那么当我没说。
不然……思想有多远就给我滚多远。
又不是叫你发明新语言特性本身实现编译器,都敢倒腾“底层”语言了,了解基本需求和解决方案这么点简单的分内之事都作很差还有脸生产垃圾污染环境让人擦屁股来添乱?
仍是有谁逼你用这坨容易炸的东西了?(不懂适应现实?那么饿死活该。)
注意,业界历来不缺猪队友,少一头的确照样转(蠢代码照样蠢)。
7 结语
略。模块化