欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

由赋值中的“别名现象”引发的思考

程序员文章站 2024-03-17 17:33:22
...

    最近在重读《Java编程思想》(第四版),进行到 第三章 操作符,在3.4节赋值部分,一个本不应该纠结的“别名现象”引发了关于形参与实参,值传递与引用传递几个基础问题的迷惑,经吸取前辈经验与亲自实践,将本部分内容整理如下,希望能对在此间问题上有所困惑的朋友有所帮助:
    首先,关于赋值“=”的概念:即 将右值复制给左值,分为基本数据类型赋值和引用类型“赋值”。基本数据类型的赋值很简单且没有疑点,只是把一个地方的内容简单复制到另一个地方,赋值操作结束后,无论操作哪一方,对另一方都不会造成任何影响,二者再无瓜葛;而对引用类型(包括对象及字符串,字符串较特殊,将在下文补充说明)的“赋值”,本质上是将对象的引用进行复制,书中例:有c,d两个对象,现执行 c=d,则c和d都指向了原本只有d指向的对象。之后书里举出了一个描述这一过程的完整代码实例,在此不再赘述了,只拿出关键的几行代码简单描述以引出“别名现象”。沿用上面的c、d两个对象,他们是同一个类的两个实例,都有非私有的成员变量name(书中此处还未涉及到封装),有:

1:
c.name = "c";
d.name = "d";
c = d;
c.name = "e";
System.out.println(d.name);  (书中此处使用前述的静态导入,简化了输出语句的写法,本例没必要)
/* Output: e */

    惭愧的讲,初看这种 对象.属性 的代码以及输出结果,我的大脑是宕机了1秒钟的,主要是 对象.属性 的用法确实遥远了,后来想到书中此处还没有涉及到封装,这种最基础最简单的用法倒是让我想到初学的情景,恍如隔世。简单解释一下我的例子(其实没必要):c,d的name属性都有各自的初值,将d赋给c,改变c的name属性,d的name属性也随之改变,这是由于赋值操作将d的引用复制给了c,使得c和d指向了同一个对象,通过其中一个(例中是c)去改变这个对象,同样指向这个对象的另一个(d)自然随之改变;就像你叫李四(本来我随手打的张三),小名小四,李四找了个女朋友,这个女朋友同时也是小四的女朋友一样。这就是书中描述的“别名现象”,或者说是“别名陷阱”我觉得更为合适。
    如何规避这一陷阱?书中的方式是 c.name = d.name ; 这样c还是那个c(c所指向的那个对象),d还是那个d,二者独立,只不过c“改名”了(name属性的值变了)。书中接着提到这种直接操作对象内的域(例中对象的成员变量)容易导致混乱,违背面向对象的原则(封装,这点相信你在看这个例子之初就应该察觉到了,如果是没概念的新手,相信你在不久之后的get/set海洋中扑腾的时候也一定会感同身受的)。这部分还是好理解的,之后是方法调用中的别名问题,这就稍稍纠结了。
    依然用前述的c,d两个对象,省略程序的其他环节,只保留方法声明和方法调用,有:

2:
void change(Letter d){
    d.name = "d";
}

c.name = "c";
change(c);
System.out.println(c.name);

/* Output: d */

    最开始看到这样的结果我是迷惑的,因为有一句话深深地印在我的脑海里,就是:形参的改变不会影响实参。看到这,请你把这句话忘掉。这个观点的完整表述应该是这样的:对于基本类型数据,字符串(特殊,最后说明),和引用本身(我指的是地址值,可能这样理解不太准确,但是我想表达的意思是 地址值)这三种形参,无论在方法内怎样改变,如果没有将更改后的值返回,并将返回值赋值给原实参,那么都不会影响原实参。基本类型无需多说;字符串只是机制特殊,表现形式与基本类型相同,稍后补充;对于引用本身我需要补充一个例子来说明,很简单:

3void change(Letter d){
    d = new Letter();
}

    如果你在方法调用前,方法内部,和方法调用后打印三次对象的地址值(我不写,写了你记不住,自己试印象深),你会清晰地发现,方法内形参引用(地址值)确实改变了,但是出了方法体,实参还是那个实参,引用(地址值)没变。但是不知你是否注意了我加粗显示的两句话,如果将变更后的形参返回并赋值给实参,那实参就会发生改变。如下:

4:
Letter change(Letter d){
    d = new Letter();
    return d;
}

c = change(c);

此时再打印三次地址值,你会发现原先的实参被改变后的形参“同化了”。
    之所以强调引用本身(地址值),是因为虽然引用本身在方法内的更改不会影响到方法外【1】(我指void),但是如果该引用指向的内容在方法内更改了,对不起,方法外实参也会做出相应改变。这是因为形参可以视为与实参有着相同的引用【2】。与例1中阐述的道理相同,这也就有了例2的结果。或者我使用如下写法,你可能恍然大悟(我反正是恍然大悟):

5void change(Letter d){
    d.setName("d");
}

    至此,我用了几个很简单很简单真的不能再简单的几个例子,和啰啰嗦嗦的解释,差不多是将“别名现象”解释清楚了(额,我觉得是……),下面补充说明字符串及值传递引用传递的问题。
    字符串(常量)对象是一种特殊的引用类型,用final修饰,存放在字符串常量池中。每一次赋值,先会在常量池中查找是否有相同对象,有则将这一对象的引用复制给左值,以完成赋值,没有则创建新对象,将新对象引用复制给左值。只不过他的表现出的现象与基本类型数据一致。如果你对前述引用指向的内容 这部分体会的不透彻,你可以自己尝试在方法内以字面量的形式给字符串赋新值(本质上是在方法内改变引用本身(指地址值),废弃原先的指向,转而指向新的字符串常量对象所在的空间)对比 使用StringBuilder对象改变内容(如append())对方法外实参产生的影响。
    至于值传递和引用传递的问题,哎….这个就比较麻烦。我看了很多博文,很多知友的回答(为此找回了失散多年的知乎密码,这些回答中,Hugo Gu的回答给我触动最大),以及*中靠前的一部分回答,现整理出来,结合自己的理解来说一说。很久以前,我对这两个概念的理解是基本类型,采用值传递;引用类型,采用引用传递。如果你一听,觉得:哎,没毛病!哈哈,那老铁咱先握个手。后来,看有的文章说,java只有值传递,因为引用传递传递的值是地址值,这不也是值么?天哪!简直不能更有道理!所以很长一段时间,我对java只有值传递的理解只停留在这个层面上,直到写这篇文章之前。学习嘛,总是在曲折中前进的,可能我现在写下的“正确答案”,在将来的某天就会觉得哎呀漏洞百出,以下的全部讨论是建立在java只有值传递,以及下述的值传递、引用传递概念的基础之上的,如果二者任一有偏差,那结论一定不准确甚至错误。值传递和引用传递的概念我看了许多,目前倾向于这种表述(希望你能理解半路出家的程序员):所谓值传递,是指在方法调用时,将实参拷贝一份,将副本作为形参传递到方法内部,这样,方法内形参的改变不会影响到方法外实参(希望你完整的看完前文,确切的知道这里的“不会影响”是指什么)。首先,要注意区分值传递和传递的内容(我不想用“传递的值”这种容易引入困惑的表述),并不是说传递的内容是基本类型数据就是值传递,传递的内容是引用类型就是引用传递了,这跟实参的数据类型是没有关系的,切切;其次,绝大部分同志都会认同这样一个现象【3】:引用类型实参并没有传递堆中实参本身,而是传递指向实参的栈中地址值,由此,“正确归一了”java中只有值传递的结论。由此,就必须先搞清楚引用传递的概念,其他的,就迎刃而解了。所谓引用传递,是指在调用函数时,将实参未经拷贝,不创建副本,直接传递到方法内部,自然,方法内形参改变一定会影响方法外实参。你一定会疑问:这不是跟【3】的表述一样么!不。【3】描述的只是一个表象,java中引用类型向方法内传递的确实是一个地址值,但是,它只是指向堆中实参的栈中地址值的一份拷贝,是一个副本,虽然这个副本地址值与原本地址值模样一毛一样,指向一毛一样,能实现的功能(修改指向空间内的内容)一毛一样,你在方法内让这个副本地址值变成了另一地址值,指向另一个空间(比如为字符串赋新值,new Object()等),方法内一切正常,出了方法,这个实参地址值副本生命周期结束,被干掉,它新指向的新的空间如果闲置,则等待垃圾回收,实参在栈中的原地址值被重新启用,并保持原指向(虽然原指向空间中的内容可能已经改变)。懵不懵?看图就懂了:
由赋值中的“别名现象”引发的思考
    至此,我们只要揪住“副本”这一核心,很多问题就清晰了(相信你回头看看【1】和【2】会有新的收获)。必须坦言,我在深度(JVM内存模型等)和广度(C,C++等)上的理解是有限的,如有错误,敬请指正!
    以上。