不应该从语法的角度去理解语言,而应该从编译的角度去尝试理解一段代码的真正过程。
语法糖,这个名字有点萌…根据中文维基百科的解释,就是如果一个语法对语言的功能没有影响,仅仅是为了方便程序员使用,让程序更加高效和更具有可读性,那么这种语法就是语法糖。与语法糖相对的,就是语法盐,就是让你不那么开心的语法,维基百科里举的例子有C++中把C的强制类型转换变为4种不同类型的转换,以此来提醒用户不要瞎转。此外我想起之前遇到的Most Vexing Parse,似乎也可以归为语法盐。
言归正传(其实也没有正啊,根本没有实力写干货…),看看常遇到的C++中的语法糖吧。注意,这种语法糖还是主要指平时不怎么留意的或者C++新增的,以及对写代码可能导致错误的点。
-
函数默认参数
来看这样一个例子:
struct B { virtual void print(string x="base"){ cout << x << endl; } virtual ~B(){} }; struct D : public B { virtual void print(string x="derived"){ cout << x << endl; } }; int main(int argc , char *argv[]) { B *pd = new D(); pd->print(); return 0; }
想想什么结果呢?之前在笔试时碰见了~可以答错了(笔试题还考了多态,这里默认多态是OK的)。
pd->print()
是一个动态绑定,在运行时会调用D
的print
函数,如果按照这么来理解,那么输出就该是derived
。可惜不是这样的,函数是多态绑定没有问题,但是默认参数在生成函数调用前其实已经变成确定的表达式了!这句话换个说法,就是多态调用在运行时完成,但是默认参数是静态编译时就确定了。而这么确定的?就是根据静态类型确定的(静态类型就是指字面类型,如pd
的静态类型就是指向B
的指针)!既然print
的调用者在静态编译时看来是指向B
的指针,那么编译器就会在B
的作用域中找,发现了print
。接着检查传入参数,发现传入参数个数为0,但需求的这个参数恰好含有默认值string("base")
,函数调用正确,于是就立即生成函数调用。这里虚构一下(肯定不是生成这个样子的,这里算是等价写法),就是这样的函数调用:pd->print(string("base"));
问题的关键出来了。默认参数立即被编译器前端给替换为一个临时变量(或字面值)!对于编译器后端,这是不可见的。所以,这显然是一个语法糖。编译器后端根本不知道有默认参数这么一回事。当上述代码变为二进制代码后,其在执行期,根据动态绑定,找到的print函数是
D
作用域下的,但是这个时候对于这个函数,它的参数已经给定了——即是说,再没有D::print
的默认参数什么事了,压入调用栈的是string("base")
.所以,我们从编译的角度去看“函数默认参数”,其根本不存在。在生成中间代码之前,默认参数已经根据静态类型找到了确定的值,并完成了替换。不要妄想动态运行时还会根据具体的动态类型去压入默认值!
PS一下,其实动态绑定这个东西不是通过虚表实现的嘛,但是虚表其实也是静态编译完成的。只不过在
B *pd = new D();
时,右边的操作会将基类B
的虚表指针改为指向D
的虚表指针。这样,虽然基类指针还是那个基类指针,但是基类指针的值已经完成了指向派生类的虚表地址了…这可真是太聪明了。于静态中造动态,太巧妙。不过还是要看到其本质,即动态绑定其实也是在静态编译过程中完成的。一句话,根本没有动态一说。千万不要把C++想成动态语言(如Python)那样的执行到那一行,那一行才真正编译(不完全确定,但根据一个现象,即如果非语法错误,比如调用了根本不含有的函数,那么其只会在执行到那一行时才报错),其所有行为在链接完成那一刻就确定了,所有的动态,都是指针指向值的变化。 -
Lambda表达式
对,作为C++11引入的Lambda表达式,其实也是语法糖。不要想太多,编译器就是将Lambda表达式等价构建了一个
匿名函数对象
罢了。值捕获、引用捕获就是相应的构造函数传值,所以一个Lambda表示写完,一个匿名函数对象就构建完成了。它是构建在栈上的,允许拷贝。所以,如果要想构造了一个跨越作用域的Lambda表示式,就把它当作一个构造一个普通对象来考虑吧——遵循正常的变量作用域规则,包括访问、销毁等。其在运行过程中的开销其实就是构建一个对象的开销,一般不会有太大性能损失(注意合理的捕获方法)(PS,其实没有做实验测试)。看看这个C++11 lambda implementation and memory model,题主就是想太多了… 此lambda根本没有闭包啦,不像JS…
-
箭头运算符-> 、下标运算符 []
对于取对象的成员,其实只有
.
运算符。箭头运算符只是语法糖。 当然它的等价形式很简单啦,如果没有重载,那就是p->m <==> (*p).m
;如果重载了,那就是p->m <==> (p.operator->())->m
, 右边是个递归的过程,最后一级必然是返回一个未重载的指针,那样递归就结束了。 话说,只能重载箭头运算符,而不能重载成员访问符.
吧(确认了下,是的!),看来这个语法糖真的蛮有用——至少就催生了shared_ptr
这一系列智能指针啊! -
成员函数
没有看错,成员函数也是语法糖。当然,从这个维度上说,整个C++都是构建在C上的语法糖…这里不深究这个,只是说理解其真正的面貌,避免犯错!
如果你会Python,那么你就明白为什么实例的函数需要多一个参数,这个参数在第一个位置,一般取名为
self
, 如class T: def __init__(self, a): self.a = a
C++其实和这个是类似的。成员函数也有这么个隐式参数,也就是一般看到的
this
指针啦。你说为啥Python就是显示的、而C++(及其Java)就要隐式呢?我想C++就是要封装一套完整的OO体系吧,想要抛弃C的写法,然而却又不得不兼容C,因此才在隐藏之下暴露出这一性质;而Python之禅“明言胜于暗示(Explicit is better than implicit.)”的指引则让同样支持OO与函数式编程的Python更加统一(即不想抛弃函数式编程那一套);而Java,完全没有这个问题,因为我完全封装了。
PS, Python中用
import this
就能打印Python之禅,感觉完全就是针对C++等的this
啊拉回来,这个有什么坑呢?很常见的,比如你偶尔傻了想要把一个成员函数绑定到普通函数指针,或者
function
对象上,编译器就会报错:错误:无法将‘B::print’从类型‘void (B::)(int)’转换到类型‘void (*)(int)’ void(*pfnc)(int) = b.print;
此外,在Lambda表达式中使用成员函数也需要注意——因为Lambda表达式是新建一个匿名函数对象,同样没有对当前类的访问权限,所以记得把
this
作为引用捕获进去,然后才能通过this
来调用。最后一个问题,怎么绑定一个成员函数呢?看看下面的示例(各种试错、查爆栈),应该就没有问题了!
#include <functional> #include <iostream> using namespace std; struct B { void print(int x ){ cout << x << endl; } virtual ~B(){} }; int main(int argc , char *argv[]) { B b; void(B::*pfunc)(int) = &B::print; // bind to pointer, as class member pointer (b.*pfunc)(4); // operator .* function<void(B*, int)> func_obj = &B::print; // bind to function, as parameter func_obj(&b, 5); auto func_bind = bind(&B::print, &b, placeholders::_1); // bind, as parameter func_bind(6); return 0; }
上面的3个例子很有意思。第一个是绑定到普通指针,这个歧义性太大了,因为它不是作为参数绑上去的,而是把它放到作用域中,调用时用
.*
运算符。这还是我第一次用这个运算符…后面两个就是前面说的了,显示把对象指针作为this
的实参。最后,一定要使用指针!即
&B::print
, 否则错误:对非静态成员函数‘void B::print(int)’的使用无效 auto func_bind = bind(B::print, &b);
一定要使用类作用域,而非实例来引用函数,即
&B::print
而非&b.print
, 否则错误:ISO C++ 不允许通过取已绑定的成员函数的地址来构造成员函数指针。请改用‘&B::print’ [-fpermissive] void(B::*pfunc)(int) = &(b.print);
绑定应该需求不大,不过理解成员函数是语法糖,有一个隐式参数,对于以后遇到想过问题时及时找到错误还是很有帮助的。
一句话,不管是否是语法糖,了解编译的过程及其其可能的汇编形式,才能使我们不被表象的糖衣代码所迷惑。