引用本质上是指针的语法糖,在本质上没有任何区别。
至于说为什么要引入这个概念呢?
分几层:
最直接的原因是为了运算符重载。因为地址类型本身已经有了很多运算定义(+/-/[]之类的),所以,为了不引起歧义,运算符重载只能作用在类实例上,那么就势必要在值语义中引入一个新的类似“指针”的概念——这就是引用。
然后,为什么非要引入运算符重载呢?主要是这样就能在代码形式上,使得类和内置类型能够使用同样形式的代码。
再接着,为什么代码形式一致那么重要呢?除了审美洁癖外,最重要的一个原因是C++的一个非常重要的东西:模板。如果没有这种代码形式的一致性,那么我们写模板,都要为类和POD类型重复特化两遍,这就非常扯淡了。
所以,很多概念,其实是一个大的体系中的一环,单独拆开来看,似乎没什么大意思。要评价它们,需要放回到整个体系中才能看得出来。
这个问题的补充说明让我不禁怀疑提问者写的C++代码行数是不是可以用手指头数出来。
#include <stddef.h> void f(int & x, int i) { x = i; } void g(int * p, int i) { if (p == NULL) { return; } *p = i; }
引用天然含有非空的语义,不需要额外做非空判断。
传指针的话,如果不信任传入的参数,则必须要做非空判断,否则一旦调用方误传空值进来,鬼知道程序接下来会有什么行为。
如果选择信任传入的参数,则调用方与被调用方必须做好约定,比如写好文档。但是又有谁来检查呢?靠人?显然靠不住。靠静态检查?它只能查点简单的,对于有弯弯绕绕的也查不出来。
一些编译器有私货可以标注参数绝对非空,但是这种引入非标准写法的做法也是下下策。
#include <stddef.h> __attribute__((nonnull(1))) void g(int * p, int i) { *p = i; } void test() { g(NULL, 0); }
况且,这种检查是软性的,即便你直接传 NULL,也没人拦着你 ——
还有稍不注意,遇上这种马虎的情况,一个警告也不会有 ——
#include <stddef.h> __attribute__((nonnull(1))) void g(int * p, int i) { *p = i; } void test(int * p) { // p 有可能为空? g(p, 0); }
所以遇上绝不可能为空的场景,还不如使用引用,彻底断绝掉隐患。
#include <stdio.h> #include <string.h> void cmp_and_swap_ref(char * & p1, char * & p2) { if (strcmp(p1, p2) > 0) { char * t = p1; p1 = p2; p2 = t; } } void cmp_and_swap_ptr(char ** p1, char ** p2) { if (strcmp(*p1, *p2) > 0) { char * t = *p1; *p1 = *p2; *p2 = t; } } int main() { char c1[] = "xyz"; char c2[] = "abc"; char * p1 = c1; char * p2 = c2; cmp_and_swap_ref(p1, p2); // cmp_and_swap_ptr(&p1, &p2); printf("%s
%s
", p1, p2); }
这种二级指针的代码拿给小白直接爆炸。
而通过引用,原作用域中该怎么用,在函数中还是怎么用,不需要多考虑要不要取址,要不要解引用的问题。
严蔚敏的《数据结构》书中自定义了一个四不像的所谓的 ”C 语言的超集语言“ ,其实就是一个允许使用引用的 C,一个不带其他任何高级语法的 C++。
这么搞就是为了照顾到不少新生才迷迷糊糊地学完 C,连指针都还没搞懂,就又不得开始学下一门《数据结构》。
C 的基础都没打牢,他根本就理解不了通过指针去修改函数外部变量的写法。
1) 使用引用,代码书写符合人类的直觉 ——
struct Foo { char c[100]; Foo(); }; Foo f(const Foo &); Foo g(const Foo &); void h(const Foo &); int main() { h(g(f(Foo{}))); }
生成的汇编十分简洁,只有 22 行 ——
2) 不让用引用的话,也不是不可以写折叠式,比如可以使用值传递 ——
struct Foo { char c[100]; Foo(); }; Foo f(Foo); Foo g(Foo); void h(Foo); int main() { h(g(f(Foo{}))); }
但这种写法有重复的对象拷贝,极度拖慢速度 ——
3) 那为了节省拷贝开销,就只能改成传址,先试试这种写法行不行 ——
struct Foo { char c[100]; Foo(); }; Foo f(Foo*); Foo g(Foo*); void h(Foo*); int main() { Foo foo; h(&g(&f(&foo))); }
十分不好意思,右值是不可以取址的,所以上面的这种写法是不可行的。
4) 那么既然要求性能,就得放弃书写的直观性和美观性。使用传址的写法,一堆具名的临时变量,奇丑无比。
struct Foo { char c[100]; Foo(); }; Foo f(Foo*); Foo g(Foo*); void h(Foo*); int main() { Foo foo; Foo tmp1 = f(&foo); Foo tmp2 = g(&tmp1); h(&tmp2); }
放弃直观性美观性,带来的性能维度的收益仅仅只是和引用的写法持平 ——
5) 对于 C 写久了的老顽固,当然没听说过什么 RVO 优化,断然不可能同意 4) 这种直接返回大对象的做法,必然会要求必须使用指针传出返回值。
struct Foo { char c[100]; Foo(); }; void f(Foo* ret, Foo* in); void g(Foo* ret, Foo* in); void h(Foo* in); int main() { Foo foo; Foo tmp1; f(&tmp1, &foo); Foo tmp2; g(&tmp2, &tmp1); h(&tmp2); }
结果是不但书写上又臭又长,性能上又还打不过 ——
struct Foo { Foo& doX() { return *this; } Foo& doY() { return *this; } Foo& doZ() { return *this; } }; int main() { Foo foo; foo.doX() .doY() .doZ(); }
有些设计模式中会用到这种返回自身的引用。
比如 boost.assign 早年实现的初始化列表 (std::initializer_list
的直接灵感来源)
#include <boost/assign/list_of.hpp> // for 'list_of()' #include <boost/assert.hpp> #include <list> using namespace std; using namespace boost::assign; // bring 'list_of()' into scope int main() { const list<int> primes = list_of(2)(3)(5)(7)(11); BOOST_ASSERT( primes.size() == 5 ); BOOST_ASSERT( primes.back() == 11 ); BOOST_ASSERT( primes.front() == 2 ); }
int a[5]; a[0] = 4; int * p = a; *p = 6;
类似于a[0]
, *p
这样的操作,其结果究竟是什么?
int
?
如果果真结果只是一个简简单单的 int
,那为什么如下的写法不行?
int at(int a[], int n) { return a[n]; } void test() { int a[5]; at(a, 0) = 2; // FAILURE }
typedef struct Node { Node * next; int i; } Node, *List; int at(Node * p, int n) { while (n--) { p = p->next; } return p->i; } void test(List l) { at(l, 3) = 0; // FAILURE }
如果我确实需要自然地封装一个复杂的访问逻辑,只能通过宏么?
(或者就只有丑陋的指针 *at(l, 3) = 0
)
这些问题是 [ ]
*
等运算符能被用来重载的基础。