不应该从语法的角度去理解语言,而应该从编译的角度去尝试理解一段代码的真正过程。

语法糖,这个名字有点萌…根据中文维基百科的解释,就是如果一个语法对语言的功能没有影响,仅仅是为了方便程序员使用,让程序更加高效和更具有可读性,那么这种语法就是语法糖。与语法糖相对的,就是语法盐,就是让你不那么开心的语法,维基百科里举的例子有C++中把C的强制类型转换变为4种不同类型的转换,以此来提醒用户不要瞎转。此外我想起之前遇到的Most Vexing Parse,似乎也可以归为语法盐。

言归正传(其实也没有正啊,根本没有实力写干货…),看看常遇到的C++中的语法糖吧。注意,这种语法糖还是主要指平时不怎么留意的或者C++新增的,以及对写代码可能导致错误的点。

  1. 函数默认参数

    来看这样一个例子:

     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()是一个动态绑定,在运行时会调用Dprint函数,如果按照这么来理解,那么输出就该是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)那样的执行到那一行,那一行才真正编译(不完全确定,但根据一个现象,即如果非语法错误,比如调用了根本不含有的函数,那么其只会在执行到那一行时才报错),其所有行为在链接完成那一刻就确定了,所有的动态,都是指针指向值的变化。

  2. Lambda表达式

    对,作为C++11引入的Lambda表达式,其实也是语法糖。不要想太多,编译器就是将Lambda表达式等价构建了一个匿名函数对象罢了。值捕获、引用捕获就是相应的构造函数传值,所以一个Lambda表示写完,一个匿名函数对象就构建完成了。它是构建在栈上的,允许拷贝。所以,如果要想构造了一个跨越作用域的Lambda表示式,就把它当作一个构造一个普通对象来考虑吧——遵循正常的变量作用域规则,包括访问、销毁等。其在运行过程中的开销其实就是构建一个对象的开销,一般不会有太大性能损失(注意合理的捕获方法)(PS,其实没有做实验测试)。

    看看这个C++11 lambda implementation and memory model,题主就是想太多了… 此lambda根本没有闭包啦,不像JS…

  3. 箭头运算符-> 、下标运算符 []

    对于取对象的成员,其实只有.运算符。箭头运算符只是语法糖。 当然它的等价形式很简单啦,如果没有重载,那就是 p->m <==> (*p).m;如果重载了,那就是p->m <==> (p.operator->())->m, 右边是个递归的过程,最后一级必然是返回一个未重载的指针,那样递归就结束了。 话说,只能重载箭头运算符,而不能重载成员访问符.吧(确认了下,是的!),看来这个语法糖真的蛮有用——至少就催生了shared_ptr这一系列智能指针啊!

  4. 成员函数

    没有看错,成员函数也是语法糖。当然,从这个维度上说,整个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);
    

    绑定应该需求不大,不过理解成员函数是语法糖,有一个隐式参数,对于以后遇到想过问题时及时找到错误还是很有帮助的。

一句话,不管是否是语法糖,了解编译的过程及其其可能的汇编形式,才能使我们不被表象的糖衣代码所迷惑。