Hoodlum1980 (fafa)'s Technological Blog

Languages mainly using and digging: C / CPP, ASM, C#, Python. Other languages:Java.

博客园 首页 新随笔 联系 订阅 管理

    【声明】本文无技术含量!在博客园上回复某个帖子,招来他的非议,我不想去细究这个人的治学态度,不想去问去管他到底有没有修改过自己的文章,对我来说没必要。我只能说不负责任,态度自大的,不严谨的人是令我失望的。但是对于一个问题,这里涉及到了“引用”,这是C++引入的一种新的形式,可以说是给程序员的一个语法上的好处,但是我翻看了BS的《The C++ Programming Lanuage》,并没有看到对引用的实现的解释。所以虽然我一直默认为引用是这样实现的,但在对别人提出自己的观点之前,我需要验证自己的“猜想”。这个问题很好去验证,所以我先给出一个最简单的试验:用一个 int 类型的引用来说明问题。

 

    输入下面的代码,我使用的是VC6.0:

void ModifyNum(int& x)
{
    x = x + 10;
}

int main(int argc, char* argv[])
{
    int a = 5;
    ModifyNum(a);
    printf("a=%d\n", a);
    return 0;
}

 

 

    上面的代码将输出 a = 15,然后用 IDA 打开编译后的 exe 文件,查看函数 main 和 ModifyNum 的代码:

.text:00401060 main            proc near               ; CODE XREF: j_mainj
.text:00401060
.text:00401060 var_44          = dword ptr -44h
.text:00401060 var_4           = dword ptr -4
.text:00401060
.text:00401060                 push    ebp
.text:00401061                 mov     ebp, esp
.text:00401063                 sub     esp, 44h
.text:00401066                 push    ebx
.text:00401067                 push    esi
.text:00401068                 push    edi

.text:00401078                 mov     [ebp+var_4], 5
.text:0040107F                 lea     eax, [ebp+var_4]
.text:00401082                 push    eax
.text:00401083                 call    j_ModifyNum
.text:00401088                 add     esp, 4
.text:0040108B                 mov     ecx, [ebp+var_4]
.text:0040108E                 push    ecx
.text:0040108F                 push    offset ??_C@_06DJNL@a?$DN?$CFd?$CB?6?$AA@ ; "a=%d!\n"
.text:00401094                 call    printf
.text:00401099                 add     esp, 8

//eax = 0 , return 0;
.text:0040109C                 xor     eax, eax
.text:0040109E                 pop     edi
.text:0040109F                 pop     esi
.text:004010A0                 pop     ebx
.text:004010A1                 add     esp, 44h
.text:004010A4                 cmp     ebp, esp
.text:004010A6                 call    __chkesp
.text:004010AB                 mov     esp, ebp
.text:004010AD                 pop     ebp
.text:004010AE                 retn
.text:004010AE main            endp

  

    注意上面的黄色背景的代码,显然函数的参数在底层上是把 int 变量的地址 (int*)作为参数传递的。那么 ModifyNum 的代码实际上不看也就能猜到了,它和 ModifyNum ( int* pX) 应该是一样的。这个代码很好找,在代码段(.text)的顶部,紧跟跳转表后面依次是 ModifyNum, main, printf, mainCRTStartup (即 PE 文件头中记录的入口点) 这几个函数。

 

.text:00401020 ModifyNum       proc near               ; CODE XREF: j_ModifyNumj
.text:00401020
.text:00401020 var_40          = dword ptr -40h
.text:00401020 arg_0           = dword ptr  8
.text:00401020
.text:00401020                 push    ebp
.text:00401021                 mov     ebp, esp
.text:00401023                 sub     esp, 40h
.text:00401026                 push    ebx
.text:00401027                 push    esi
.text:00401028                 push    edi

.text:00401038                 mov     eax, [ebp+arg_0]
.text:0040103B                 mov     ecx, [eax]
.text:0040103D                 add     ecx, 0Ah
.text:00401040                 mov     edx, [ebp+arg_0]
.text:00401043                 mov     [edx], ecx
.text:00401045                 pop     edi
.text:00401046                 pop     esi
.text:00401047                 pop     ebx
.text:00401048                 mov     esp, ebp
.text:0040104A                 pop     ebp
.text:0040104B                 retn
.text:0040104B ModifyNum       endp

 

    上面的代码的实现显然就是针对指针操作,也就是说,ModifyNum 的实现相当于:

 

void ModifyNum(int* pX)
{
    *pX = *pX + 10;
}

 

    我也观察了下面的代码在汇编级别的实现(汇编代码就不贴了):

    int a =5;

    int& b = a;

    这里在汇编级别,b 相当于是一个 int* 类型的临时变量,和 int* b = &a 等效。当然在语言层面上我们可以理解成“b 是 a 的别名,b 就是 a”,只是看起来是这样,但它并不是实现,尤其是作为参数传递的时候编译器只能使用指针去实现。而且非常重要的是,b 作为 a 的引用,它是一个指向 a 的指针变量,它是需要在栈上额外占用存储空间的(如果理解成别名,有可能会误以为 b 不需要占用存储空间,这是不确切的)。

 

也就是说,C++中引用是编译器通过指针实现的,但这个实现在语言层面对程序员做了透明化处理。

 

    很显然,在C++里,如果一个函数需要使用(读)一个比较大的对象中的数据(而不是修改它),和在栈上构造出一个临时拷贝比起来,传递他的指针/引用是更高效的,《The C++ Programming Language》这本书中指出,这种情况,参数类型应该加 const 即 const T&,这在语义上明确表示,你仅仅是使用而不是修改它。相对的,如果不加 const,则意味着你想明确的在函数中修改对象。在规模越大的项目中,这种约定对代码可理解性起到的作用越大。

 

    现在很多上层表述有一些“按引用传递”,“按值传递”,这种表述,我想它是比较模糊一点的,他们实际上意味着前者是传递了对象的地址,在函数中因此可以修改对象,即为所谓的引用。后者,按值传递,意味着栈上是一个对象的拷贝,所以在函数中修改的是栈上的临时对象(当然临时对象是没必要修改的),而不能影响函数以外的那个对象。由于参数是通过栈通知给函数,所以只有“拷贝”(即 push)这个“传递”动作(所以你可以说底层上不存在前述的那些说法,那只是站在函数调用功能的上层角度来说的,而函数调用的底层实现只有按值传递一种,不管数据是从那里 push 到栈上的,栈上的数据都是从函数外传入,且之后对栈上参数的值的修改和传入的那个“源”无关),“按引用”和“按值”指的主要是参数意义(以及函数如何使用参数,这和参数意义是相关的)。例如,如果参数是一个地址,你可以通过这个地址读写它指向的对象即按引用方式,而你对这个地址的修改是无意义的,不会影响到函数以外的任何指针变量之类的东西。所以如果你想在函数里修改一个整数,传递它的地址,即整数的指针。如果修改一个指针,传递指针的地址,即指针的指针,修改一个对象,传递它的地址,。。。不论你想改的是什么(T),传递它的地址(参数类型是 T*),而不是它的值(拷贝),然后在函数里去解析引用(dereference)。

 

    顺着这个话题说下去,说的更精确一些。在 C# 里,假设一个对象 T,一个函数 void foo(T t); 存在下面的代码:

    T t = new T(); //或者 T t = null;

    foo(t);

    如果 T 是引用类型(class),它是按引用传递的,函数可以修改 T 的成员变量,但是不能修改 T 的指向。即函数foo调用后,t 的指向不会发生变化,依然是原来的对象,不能从 null 变为 其他对象,也不会被修改为 null。

    如果 T 是值类型(struct),它是按值传递的,函数对 T 的成员的修改只是针对栈上的临时拷贝,而不会影响外面的 t。例如如下 c# 代码:

 

struct StructA
{
    public int num;
    public int x;
    public int y;
    public StructA(int _n)
    {
        num = _n;
        x = 0;
        y = 0;
    }
    public StructA(int _n, int _x)
    {
        num = _n;
        x = _x;
        y = 0;
    }
}

static void foo3(StructA a)
{
    a.num = 300;  
}
static void foo4(ref StructA a)
{
    //a = new StructA(50, 1000);
a.num = 400; } static void Main(string[] args) { StructA a = new StructA(10); foo3(a); Console.WriteLine("a.num = {0}", a.num); //a.num=10 foo4(ref a); Console.WriteLine("a.x = {0}", a.x); Console.WriteLine("a.num = {0}", a.num); //a.num=400 }

 

    由于 foo3 函数中 StructA 是“按值传递”,所以函数内对对象的修改并不能影响到函数之外的那个“源对象”。加了 ref 参数以后,它相当于是引用类型的“按引用传递”。对于引用类型的对象来说,ref 使函数中不仅可以修改对象的成员,还可以修改 a 的指向指向另一个对象,也就是可以修改“指向”和“被指向对象的内容”[1]。 

 

    out 参数的应用场景更加明确,要求函数必须明确的修改一个指针的指向或者值类型的值。而对 ref 来说,对被指向的变量(注意这个变量通常已经是一个对象的指针)的使用是自由的,即你可以不修改而仅仅使用它。相对于 out ,用处是,就是一个对象在传入函数时可能没有赋值过(可能是 null ),在函数里如果发现它是 null 就创建它(要求影响到外部变量),其他情况我们使用或修改它,这时候就应该加 ref 了。

 

    在C#中由于完全 OO 的需要,所以隐藏了指针,而代之以“引用类型”的对象,所以对于一个引用类型的对象 T,在C++里相当于对象T 的指针,在参数上加 ref 在 C++ 里相当于指针的指针,即二级指针 T**。所以如果参数类型加 ref 意味着要修改一个指针变量的指向,这也就意味着你想在函数里对函数以外的那个对象重新赋值,即让它指向其他对象或者 null。如果仅仅修改或读取对象 T 的成员,就无须加 ref,因为引用型对象本身已经是指针了!这是在函数参数前面是否加 ref 的应用场景,对于 C++ 因为必须有内存模型的概念,所以这非常自然,可以毫无歧义的理解清楚。但对于 .net 程序员可能很难搞清楚这里的原因和区别。

 

    希望每个人都能严谨的对待技术,而不是觉得自己非常了不起,听不进任何批评意见。PS:我尽可能去除了本文中的主观评论成分。

 


    [1]  上面给出的范例代码中值类型的 a 在 C++ 对应着什么取决于 C# 底层的内存管理,根据 MSDN 的说法:

    “值类型对象(例如结构)是在堆栈上创建的,而引用类型对象(例如类)是在堆上创建的。两种类型的对象都是自动销毁的,但是,基于值类型的对象是在超出范围时销毁,而基于引用类型的对象则是在对该对象的最后一个引用被移除之后在某个不确定的时间销毁。对于占用固定资源(例如大量内存、文件句柄或网络连接)的引用类型,有时需要使用确定性终止以确保对象被尽快销毁。有关更多信息,请参见 using 语句(C# 参考) ”。

    “基于值类型的对象是在超出范围时销毁”,这句话印证了值类型对象是分配在函数的栈上的,因此一旦离开函数,这个对象占用的空间也就会被释放,所以被销毁的时间是确定的,即函数返回的时刻。而堆上的对象,由于依赖 GC 回收,所以销毁的时间是不确定的。

    值类型的 a 分配在栈上,相当于栈上临时对象,也就是 a 在 c++ 中对应的是对象本身(非指针),因此加了 ref 对应的是指针。所以 c# 中的值类型即使加了 ref 也是无法修改指向的(因为 c# 的语法达不到 c++ 中的二级指针,当然这个操作对“值类型”来说也没什么必要,“指向一个值类型的指针变量”和“改变指向”属于建立在“线性内存”模型上的说法,在完全面向对象时,没有这个模型,也没有“指向值类型对象的指针”的概念,也就无所谓有“修改指向”这个需求),而只能修改指向的对象的内容。当然这是由值类型的语言意义决定的,在 c++ 中 struct 的主要是数据封装手段,并没有上升到“值类型”这种语言意义高度去特殊看待,所以 c++ 中的struct,可以分配到栈上,堆上,其指针也是能通过函数修改指向的,因为它没有语言上的特殊含义,所以和 class 的操作没有特别区别。而在 c# 中的 struct 被赋予“值类型”的语言级别的意义,一些语言层次的代码的含义就会不同,例如比较和赋值。

    如果函数 foo4 中调用 a = new StructA(...),则这句话修改的并不是 a 的指向,而是把一个新的临时对象的内容拷贝到 a,因为 a 是值类型,赋值操作是一种内容拷贝。例如下面的代码中b = a 的赋值代码的效果,如果是值类型,a,b 是两个独立对象,如果是引用类型,a,b 将指向同一个对象:

 

    StructA a = new StructA();
          a.num = 1980;
          StructA b = a;    //因为StructA是值类型,因此 b 独立于 a 的另一个结构体!
          b.num = 2012;
          Console.WriteLine("a.num = {0}", a.num);  //a.num = 1980
          Console.WriteLine("b.num = {0}", b.num);  //b.num = 2012

posted on 2012-06-19 09:13  hoodlum1980  阅读(10087)  评论(11编辑  收藏  举报