`
RednaxelaFX
  • 浏览: 3009516 次
  • 性别: Icon_minigender_1
  • 来自: 海外
社区版块
存档分类
最新评论

关于带副作用的表达式的一点笔记

阅读更多
作者: RednaxelaFX
日期: 2007-09-28

最近周围有好多同学都在看各种面试"宝典",其中最多的还是程序方面的咯.然而这些"宝典"的质量参差不齐,有些很明显是错的还写在上面,让人颇为无奈.嗯这两天也被问到一些问题,今天就先对C#和Java中带副作用的表达式,主要是前/后自增/自减运算符的特性的问题做点笔记.正好之前执竞也问过我几乎一样的问题,还有点印象.希望读者通过本文能记住一点: 千万不要写出 i = i++; 这样的语句!!

(2013-01-20更新:别说,还真有人在生产代码中写这种东西的。看OpenJDK里这changset修的bug: http://hg.openjdk.java.net/hsx/hotspot-rt/hotspot/rev/b5f6465019f6


使用C#或Java编程,意味着要使用imperative programming style来写代码.虽然C#中lambda表达式给出了一点functional programming的味道,但它到底能对C#的整体使用状况有多少改变,还需要观察.
Imperative programming style,即命令式编程,最大的特点是程序是按命令的顺序来执行的,"表达式"中可以含有副作用,而这些副作用有先后依赖关系.同一"表达式"被多次使用时不一定会返回同样的结果.
Functional Programming style,即函数式编程,则不允许副作用,因此一个表达式无论在什么时候执行都一定会得到一样的结果.纯函数式编程语言中也没有"变量",因为所谓"变量"也只能赋初值而不能在后续运行中改变其值.这样让表达式的语义更加清晰,而且也便于lazy evaluation和并发执行.

在C#/Java中,表达式里有方法调用或一些运算符都有可能带来副作用.有副作用的运算符主要是++和--两组.这里就这两组运算符的特性展开讨论.只对语言表象感兴趣的可以只读本文的前半部分及其总结,而对运行机制感兴趣的请耐心读完IL(中间语言)分析的部分.

===============================================================

我们可以分别用C#和Java写出语句几乎一样的测试代码:

C#:
public class TestIncrCS
{
    public static void Main( string[ ] args )
    {
        Foo1();
        Foo2();
        Foo3();
    }

    public static void Foo1( )
    {
        int i = 0;
        i = i++;
	// i == 0
    }

    public static void Foo2( )
    {
        int i = 1;
        i = ++i;
	// i == 2
    }

    public static void Foo3( )
    {
        int i = 2;
        i = i++ + i++ + ++i;
	// i == 10 ← 2 + 3 + 5
    }
}


Java:
public class TestIncrJava
{
    public static void main( String[ ] args ) {
        foo1();
        foo2();
        foo3();
    }

    public static void foo1( ) {
        int i = 0;
        i = i++;
	// i == 0
    }

    public static void foo2( ) {
        int i = 1;
        i = ++i;
	// i == 2
    }

    public static void foo3( ) {
        int i = 2;
        i = i++ + i++ + ++i;
	// i == 10 ← 2 + 3 + 5
    }
}


可以看到这两种语言要编写功能相近的程序时,代码确实是几乎一样的,只是一些code convention有所不同.表面上是相同了,运行结果和背后的运行过程又是否一样呢?
上面的例子里,分别用C#和Java两种语言,在程序入口的方法调用了三个测试方法.各测试方法中,局部变量i在方法结束时的结果我都写在注释里了,发现C#与Java的测试运行结果是一样的.

估计有很多人在看到i = i++;之后i == 0要诧异了."按道理"说i++之后i等于1,然后赋值给i,所以i应该是1才对,不是么?
不是.
在C#与Java中都一样,从语言表象上看,表达式的运算从左向右进行,赋值从右向左执行;前缀自增减运算符在一个运算之前执行,后缀自增减运算符在一个运算结束之后才执行.一个运算结束,就是指求出具体运算结果之后.举例来说,一个双目运算符在运算前必须要先按顺序将左操作数与右操作数的结果求出来;假如左操作数中有别的运算,那么就要先把那些运算的结果求出来,同时这个结果带来的副作用也会影响后续的运算.

所以Foo1()/foo1()中,赋值符"="意味着一个运算的结束,而右侧的i++是在运算结束之后才执行的;左侧的i得到的是原本i就保有的值,也就是0;而i++的效果则被冲掉了.
Foo2()/foo2()中,右侧的++i在进入表达式之前就得到了执行,所以左侧的i得到的是自增后的结果.
Foo3()/foo3()中,执行的过程是:
* i被初始化为2
* 然后先对第一个 i++ 求值,这个子表达式的值为2,而其产生的副作用使得i现在为3
* 然后对第二个 i++ 求值,这个子表达式的值为3,而其产生的副作用使得i现在为4
* 前两个子表达式的值求和为5
* 然后对 ++i 求值,这个子表达式要在求出值前先完成副作用,使得i现在为5,然后这个子表达式的值为5
* 前两子表达式之和(5)与最后一个表达式的值(5)求和为10


结论很清晰了.C#与Java都规定了表达式运算和赋值的顺序,以及副作用的发生顺序,所以行为总是确定的.即使这样,好的编程习惯是尽量不要在表达式中使用带副作用的操作,特别是不要在同一表达式中对同一变量做多次有副作用的操作,否则代码会十分难以阅读.
也不知道那些公司在出面试题的时候为什么尽出些公司内编码规范里不允许使用的语言特性...我几乎肯定Sun和金山都不会允许公司内的程序员写这种代码.

===============================================================

上面总结了C#与Java中++和--在语言表象上的特性,你或许会感到好奇:同样是C-like语言,为什么这里不讨论C/C++? 好吧,简单带过一下也好,不过在那之前得特别提醒注意的是: C/C++的语言规范中并没有规定表达式的运算顺序.也就是说,从左向右计算可以,反之亦可.当表达式里不存在副作用时,这没什么影响.但当表达式中含有后缀自增减运算符时,问题就比较麻烦了.因为语言规范没有定义,所以各个编译器实现的运算顺序都未必一样.例如说gcc 3.x与gcc 4.x中的运算顺序就不相同.所以在C/C++中讨论带副作用的表达式的问题没有任何意义——要讨论也不过是在讨论编译器特性而以.
还是简单带过一个例子来看看,在Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 15.00.20706.01 for 80x86这个编译器中,对下面代码执行的状况:

C++:
#include <iostream>
using namespace std;

void main() {
	int i = 0;
	i = i++;
	cout << i << endl; // 1 ← 1

	i = 0;
	i = i++ + i++;
	cout << i << endl; // 2 ← 1 + 1

	i = 0;
	i = ++i + ++i;
	cout << i << endl; // 4 ← 2 + 2

	i = 0;
	i = ++i + i++;
	cout << i << endl; // 3 ← 1 + 2

	i = 0;
	i = i++ + ++i;
	cout << i << endl; // 3 ← 2 + 1

	i = 0;
	i = i++ + i++ + i++ + ++i;
	cout << i << endl; // 7 ← 2 + 2 + 2 + 1

	i = 0;
	i = ++i + i++ + i++ + i++;
	cout << i << endl; // 7 ← 1 + 2 + 2 + 2
}


运行结果也写在注释里了.可以看到,VC9的C++编译器保证了所有前缀自增减运算一定在进入表达式之前执行;同时,后缀自增减运算一定在表达式之后、赋值之前执行.同一表达式中的同一变量无论左右的先后顺序,值都是一样的(于是与前面C#/Java就不一样了).所以在最后两个测试表达式中,无论i出现的先后顺序如何,其中的i总是等于1(因为被++i了).
再次提醒注意,这个例子只说明VC9的C++编译器的编译器特性,不代表其它版本的VC编译器都一样,也不代表别的C++编译器也一样.
无论如何,这个例子多少说明了基于寄存器的x86指令集与基于栈的MSIL/JVM指令集在运行行为上有所不同,不应该混为一谈.

===============================================================

前面是以便于理解为出发点,从语言表象上讨论了C#与Java中自增减操作符的特性.按照上面的结论去理解C#与Java中的表达式在结果上没有问题,但对过程的描述并不准确.
刨根问底的人不该止步于此,而应该进一步了解这些表达式到底是如何被机器执行的.只有在更接近底层的层次了解表达式的运算,才能更好的理解C#与Java的实际语言特性.

前面C#与Java的例子,编译之后再反编译,可以得到中间代码的文字形式.C#的中间代码是Microsoft Intermediate Language (MSIL),也称为Common Intermediate Language (CIL).Java的中间代码没有什么特别的名字,下文将其称为JVM bytecode;由于表述有点麻烦,下面将不区分这些中间语言的二进制形式与文字形式,请根据上下文判断.
我们来看看,先前C#与Java的例子反编译出来是什么样的.

C# → MSIL:

//  Microsoft (R) .NET Framework IL Disassembler.  Version 3.5.20706.1
//  Copyright (c) Microsoft Corporation.  All rights reserved.

// ...assembly metadata omitted

// =============== CLASS MEMBERS DECLARATION ===================

.class public auto ansi beforefieldinit TestIncrCS
       extends [mscorlib]System.Object
{
  .method public hidebysig static void  Main(string[] args) cil managed
  {
    .entrypoint
    // Code size       20 (0x14)
    .maxstack  8
    IL_0000:  nop
    IL_0001:  call       void TestIncrCS::Foo1()
    IL_0006:  nop
    IL_0007:  call       void TestIncrCS::Foo2()
    IL_000c:  nop
    IL_000d:  call       void TestIncrCS::Foo3()
    IL_0012:  nop
    IL_0013:  ret
  } // end of method TestIncrCS::Main

  .method public hidebysig static void  Foo1() cil managed
  {
    // Code size       10 (0xa)
    .maxstack  3
    .locals init (int32 V_0)
    IL_0000:  nop
    IL_0001:  ldc.i4.0
    IL_0002:  stloc.0
    IL_0003:  ldloc.0
    IL_0004:  dup
    IL_0005:  ldc.i4.1
    IL_0006:  add
    IL_0007:  stloc.0
    IL_0008:  stloc.0
    IL_0009:  ret
  } // end of method TestIncrCS::Foo1

  .method public hidebysig static void  Foo2() cil managed
  {
    // Code size       10 (0xa)
    .maxstack  2
    .locals init (int32 V_0)
    IL_0000:  nop
    IL_0001:  ldc.i4.1
    IL_0002:  stloc.0
    IL_0003:  ldloc.0
    IL_0004:  ldc.i4.1
    IL_0005:  add
    IL_0006:  dup
    IL_0007:  stloc.0
    IL_0008:  stloc.0
    IL_0009:  ret
  } // end of method TestIncrCS::Foo2

  .method public hidebysig static void  Foo3() cil managed
  {
    // Code size       22 (0x16)
    .maxstack  4
    .locals init (int32 V_0)
    IL_0000:  nop
    IL_0001:  ldc.i4.2
    IL_0002:  stloc.0
    IL_0003:  ldloc.0
    IL_0004:  dup
    IL_0005:  ldc.i4.1
    IL_0006:  add
    IL_0007:  stloc.0
    IL_0008:  ldloc.0
    IL_0009:  dup
    IL_000a:  ldc.i4.1
    IL_000b:  add
    IL_000c:  stloc.0
    IL_000d:  add
    IL_000e:  ldloc.0
    IL_000f:  ldc.i4.1
    IL_0010:  add
    IL_0011:  dup
    IL_0012:  stloc.0
    IL_0013:  add
    IL_0014:  stloc.0
    IL_0015:  ret
  } // end of method TestIncrCS::Foo3

  .method public hidebysig specialname rtspecialname 
          instance void  .ctor() cil managed
  {
    // Code size       7 (0x7)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  call       instance void [mscorlib]System.Object::.ctor()
    IL_0006:  ret
  } // end of method TestIncrCS::.ctor

} // end of class TestIncrCS


// =============================================================

// *********** DISASSEMBLY COMPLETE ***********************
// WARNING: Created Win32 resource file D:\TestIncr\TestIncrCS.res


Java → JVM bytecode
Compiled from "TestIncrJava.java"
public class TestIncrJava extends java.lang.Object{
public TestIncrJava();
  Code:
   0:	aload_0
   1:	invokespecial	#1; //Method java/lang/Object."<init>":()V
   4:	return

public static void main(java.lang.String[]);
  Code:
   0:	invokestatic	#2; //Method foo1:()V
   3:	invokestatic	#3; //Method foo2:()V
   6:	invokestatic	#4; //Method foo3:()V
   9:	return

public static void foo1();
  Code:
   0:	iconst_0
   1:	istore_0
   2:	iload_0
   3:	iinc	0, 1
   6:	istore_0
   7:	return

public static void foo2();
  Code:
   0:	iconst_1
   1:	istore_0
   2:	iinc	0, 1
   5:	iload_0
   6:	istore_0
   7:	return

public static void foo3();
  Code:
   0:	iconst_2
   1:	istore_0
   2:	iload_0
   3:	iinc	0, 1
   6:	iload_0
   7:	iinc	0, 1
   10:	iadd
   11:	iinc	0, 1
   14:	iload_0
   15:	iadd
   16:	istore_0
   17:	return

}


以C#中的Foo1()与Java中的foo1()为例详细解释一下.其它的几个方法,请读者自行思考.

C#的Foo1()中,

.maxstack  3             // 该方法的栈最多使用3个单元的空间
.locals init (int32 V_0) // 该方法的局部变量声明,这里是一个Int32类型的局部变量,叫做V_0,对应于原C#代码中的i

IL_0000:  nop            // 空指令
IL_0001:  ldc.i4.0       // 装载一个32位的整形常量,值为0.将这个数压到栈上.
IL_0002:  stloc.0        // 将栈顶的值弹出,放入局部变量区中下标为0的单元中,也就是赋值给V_0
IL_0003:  ldloc.0        // 从局部变量区中下标为0的单元取出值,并压到栈顶
IL_0004:  dup            // 将栈顶的值复制一份,同样压入栈中
IL_0005:  ldc.i4.1       // 装载一个32位的整形常量,值为1.将这个数压到栈上.
IL_0006:  add            // 从栈顶弹出两个值,将它们相加,然后压回到栈中
IL_0007:  stloc.0        // 将栈顶的值弹出,放入局部变量区中下标为0的单元中,也就是赋值给V_0
IL_0008:  stloc.0        // 将栈顶的值弹出,放入局部变量区中下标为0的单元中,也就是赋值给V_0
IL_0009:  ret            // 方法返回,无返回值

其中,IL_0001与IL_0002是由int i = 0;编译而来.IL_0009是方法结束的标示.中间的就是i = i++;编译而来的MSIL.
IL_0003,IL_0004分别将i等于0的值压入栈中.IL_0005将常量1压入栈中,IL_0006让栈顶的两值相加,IL_0007将栈顶的值弹出,存到变量中.也就是说,IL_0004-IL_0007完成了i++.
到IL_0008执行前,栈顶还留有IL_0003压入的那个0.而正是这个0,将i++的效果冲掉了,将i的值重新赋为0.

Java的foo1()中,

0: iconst_0     // 装载一个32位的整形常量,值为0.将这个数压到栈上.
1: istore_0     // 将栈顶的值弹出,放入局部变量区中下标为0的单元中,也就是赋值给i
2: iload_0      // 从局部变量区中下标为0的单元取出值,并压到栈顶
3: iinc 0, 1 // 将局部变量区中下标为0的单元自增1,直接保存在原单元中
6: istore_0     // 将栈顶的值弹出,放入局部变量区中下标为0的单元中,也就是赋值给i
7: return       // 方法返回,无返回值

Java版本与C#版本其实是完全对应的,所以不用详细讲解了.
指令0对应IL_0001,
指令1对应IL_0002,
指令2对应IL_0003,
指令3对应IL_0004-IL_0007
指令6对应IL_0008
指令7对应IL_0009

===============================================================

对"栈"这一概念不熟悉的读者可能会觉得奇怪,i等于0明明是先入栈的,为什么反而会将在后的i++的效果冲掉了呢?
这是因为栈是一种LIFO(先入后出)的数据结构,所以进入栈的顺序与退出栈的顺序正好的相反的.所以当我们看到一对load/store在中间i++代码的"外面"时,我们就已经可以知道最终的运算结果了.
如果因为先入为主,只知道以基于寄存器的模式去思考的话,要理解这基于栈的MSIL/JVM指令集确实是由点别扭...

=================续=================
(2007-10-07)

之前的那篇是关于C#/Java的,这次把DMD 2.004编译的D的代码的一些特性拿来说说.

以前也有听说过"D语言"这个名词,但没仔细接触前,一直都以为D语言的运行时也是个VM,后来才发觉D语言的GC,RTTI等功能都是直接通过编译到native code的library实现的,而D的代码也是编译为native code之后,与运行时link起来形成目标文件.

那就是说,可以用常规的调试器来观测D语言的其中一种编译器,DMD的编译结果了.

测试程序代码: http://bestimmung.iblog.com/get/222303/program.d
编译结果经过反汇编得到的代码: http://bestimmung.iblog.com/get/222303/program.d.asm

具体内容请点击链接查看.源代码和编译出来的结果都很直观,所以不多做解释.下面只把与"副作用"相关的一部分代码的运行结果说明一下:

int j = 0;
j = j++ + j++ + ++j + ++j; // j == 8 == 0 + 1 + 3 + 4


读了这篇的前文的话,应该知道该如何分析这些副作用的发生,这里也不多说了,有必要的话参考前文吧.所以说这DMD 2.004编译器的特性跟C#/Java都不一样.与C/C++相同,这种特性只能认为是编译器特性而不是语言特性,请务必注意.

=================续=================
(2007-10-22)
顺便看了一下,JavaScript里在运算优先级,顺序等方面基本上都跟Java/C#一样.这就比较方便了,以后可以少记些东西
1
0
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics