赋值语句、求值顺序和序列点

这个问题缘起网上著名的a=b=c的讨论,各种语言都有针对这个细节问题的讨论。

让我们来看一看这连续赋值在各种语言的行为,以及表象背后的语言设计本身。

y = x = {a : 1};
document.write(y.a + " " + x.a + "\n");

结果输出1和1,没有什么问题,如果不考虑y和x的作用域上可能存在的差别,上面的代码就相当于:

x = {a : 1};
y = x;

关于Javascript赋值语句的语义,可以参考http://deltamaster.is-programmer.com/posts/43687.html这篇文章。

而这个时候我又在网上发现了关于一种诡异代码的讨论:

x.n = x = {a : 2};
document.write(x.n + " " + x.a + "\n");

结果是undefined和2。这个时候如果我把这个语句拆开来写成两句,结果就不是这样:

x = {a : 2};
x.n = x;
document.write(x.n.a + " " + x.a + "\n");

这时候输出的结果是2和2。那么问题就来了,为什么这种情况下结果不同呢?

经过翻阅ECMAScript标准相关章节http://www.ecma-international.org/ecma-262/5.1/#sec-11.13,找到了蛛丝马迹。根据标准规定,对于赋值语句,总是先对lhs求值,再对rhs求值,然后PutValue。然后来看看求值顺序对结果的影响,我们一步一步来分析上面这条语句。

假设执行语句之前,x是一个空对象。第一步,首先对x.n进行求值,x没有属性n,那么为x添加属性n,左值的求值结果就是对刚才添加的属性n的引用。

上面图中蓝色箭头表示引用关系。

第二步对右值进行求值,右值是x = {n : 2}。递归向下,先对左值求值,得到x,按照上图的话,应该也是引用Object A,然后对右值{a : 2}求值,得到Object B,接着PutValue将改变x的引用目标到Object B,赋值表达式x = {n : 2}返回Object B。

这个时候x和Object A已经解绑,那么相应的x.n也已经和Object A无关了,x.n的指向与刚才第一步求值得到的引用也没有了联系,而Object B这时候没有属性n,所以x.n这个时候是undefined。

第三步,PutValue将lref指向Object B。不过这个时候lref指向的属于Object A的属性n,已经没有被其他资源指向了,Object A的属性n也不再是x的属性n。

这样整个赋值过程就完成了,得到了x.n为undefined的结果。

让我们再来看看用C++尝试做类似的事情会是什么结果(下面的代码请使用支持C++11语法的编译器编译):

#include <iostream>

class A;

class A
{
public:
    A* n = nullptr;
    int a = 0;
};

int main()
{
    A o;
    o.a = 2;

    A x;
    x.n = &(x = o);

    std::cout << x.n->a << std::endl;

    return 0;
}

输出的结果是2。不过,这时候编译器给出了警告:main.cpp|18|warning: operation on 'x' may be undefined [-Wsequence-point]|。

这里面有两个问题。第一,C++的赋值语句返回的是左值,而其他许多语言(包括C语言,还有上面提到的Javascript)返回的是右值。因此上面的代码x.n = &(x = o)在C语言中是无法通过编译的,因为取地址运算的操作数必须是左值。第二,C、C++有序列点的概念,在两个序列点之间,如果发生多次副作用,那么这些副作用发生的顺序是不确定的(可以自行搜索关于“序列点”和“副作用”),只是在上面的这个实验中,x = o的副作用先发生了。

不同程序语言中引用、参数的引用传递及赋值运算符的语义

问题缘起一位同学在技术聚会上提出的关于Java参数传递方式的讨论。

public class HelloWorld{
    
    public void f1(A a)
    {
        a.x = 5;
    }
    
    public void f2(A a)
    {
        a = new A();
        a.x = 10;
    }

    public static void main(String []args)
    {
        HelloWorld o = new HelloWorld();
        A a = new A();
        o.f1(a);
        System.out.println(a.x);
        o.f2(a);
        System.out.println(a.x);
    }
}

上面给出了一段简单的Java程序,问程序的输出结果是什么。

我作为一名C++程序员,理所当然地觉得既然Java对于对象的参数传递方式是引用传递,那么就应该像C++一样,函数内的局部参数a不管被赋予了什么值,都始终应该等同于调用处的a,那么结果就应该是5和10了。不过事实证明我还是太年轻了,正确的结果应该是5和5。

要解决这个问题,我们得从引用、参数的引用传递以及赋值运算符的语义说起。于是回家之后我用了许多不同的语言进行试验,并尝试总结一些语言设计的观点。

在C中,赋值语句的含义是用rhs的值代替lhs的值,求值以后rhs与lhs除了在值上相同,没有任何关系。参数传递的时候也是类似,将实参的求值结果来构造函数的形参,形参值的改变也无法影响到实参。使用指针的方式传递参数(本质还是值传递)的话,对于指针所指向的资源的修改则是双方都可见的,不过如果函数改变了指针本身的值,也是不会影响到实参的。

在C++中新增加了引用的概念(相比C),从语义上讲,C++的引用就是别名,如果b是a的引用,那么b在它的生命周期内就永远代表a,任何对于b的操作完全等同于对于a的操作。不能改变引用目标,引用初始化时也必须指向一个有效的目标。所以函数参数如果使用引用传递,则对于形参的一切修改都等同于对实参的操作。

#include <iostream>

class A
{
public:
    int x = 0;
};

void f1(A& a)
{
    a.x = 5;
}

void f2(A& a)
{
    a = *(new A); // memory leak
    a.x = 10;
}

void f3(A* a)
{
    a->x = 5;
}

void f4(A* a)
{
    a = new A; // memory leak
    a->x = 10;
}

int main()
{
    A a;
    f1(a);
    std::cout << a.x << std::endl; // 5
    f2(a);
    std::cout << a.x << std::endl; // 10

    A b;
    f3(&b);
    std::cout << b.x << std::endl; // 5
    f4(&b);
    std::cout << b.x << std::endl; // 5

    return 0;
}

上面这段简单的C++程序有一些内存泄露,不过为了说明问题方便,请暂时不要在意这些细节。

程序中f1和f2为引用传递,f3和f4为指针传递,所做的事情与上面Java程序想要做的类似。所有函数的执行结果都写在了调用行后面的注释,根据上面对C++指针传递和引用传递的语义说明,结果都是意料之中的。

在这里指针传递的方式结果与Java是相同的。由此,在Java的参数传递的语义,类似于C的指针传递。因此Java即不支持对于对象的值传递,也不支持类似C++的引用传递。要模拟值传递,必须使用clone方法。

所以,Java的赋值运算符和参数传递语义都与C/C++不同。在Java中,赋值运算符的语义是,令lhs引用rhs所引用的资源;相应的,参数传递则是令形参引用实参所引用的资源。在顶上的Java程序的f2中,我将新建的对象赋值给形参a,则实际上令形参a指向了新建的对象,此时形参a和实参a的指向就不同了,而形参a原先指向的资源,引用计数减少了1,此时还有实参a指向它,等实参a的生命周期结束,这个资源没有其他人指向的时候,就可以自动被GC回收。

许多其他语言在这个问题上的处理与Java相同。

Javascript:

function f1(o)
{
  o.x = 5;
}

function f2(o)
{
  o = {x : 0};
  o.x = 10;
}

var a = {x : 0};
f1(a);
document.write(a.x + "\n"); // 5
f2(a);
document.write(a.x + "\n"); // 5

PHP:

class A
{
  public $x = 0;
}

function f1($a)
{
  $a->x = 5;
}

function f2($a)
{
  $a = new A();
  $a->x = 10;
}

$a = new A();
f1($a);
echo "{$a->x}\n"; // 5
f2($a);
echo "{$a->x}\n"; // 5

Java、Javascript、PHP为什么不实现类似C++的引用?Java中的赋值运算符和参数传递,只有一种语法,如果选择C++引用的语义,那么就要丢失指针语义所带来的灵活性(指针的表达能力要强于引用);而如果引入其他语法规则,那么又需要重新引进指针概念。

而PHP恰恰提供了令一种参数传递的语法和赋值语法(=&),实现与C++功能相同的引用。

class A
{
  public $x = 0;
}

function f1(&$a)
{
  $a->x = 5;
}

function f2(&$a)
{
  $a = new A();
  $a->x = 10;
}

$a = new A();
f1($a);
echo "{$a->x}\n"; // 5
f2($a);
echo "{$a->x}\n"; // 10

所以,无论语言设计的细节是怎样,在赋值运算符和参数传递的语义上,始终可以参照C++的值传递、指针传递和引用传递来进行比较。