A 值传递-赋值兼容性检查
在开始指针讲解之前,需要引入一些基础概念。
1、左右值
我们可以将赋值语句抽象出来,凡是发生了值的拷贝传递都统称为值传递。
对于A = B来说,A是左值,B是右值。那么,非const的变量都可以做左值;所有的表达式和常量/变量都可以做右边值。
左值:写入操作,可改变值的,位于赋值运算左边的变量或表达式
我们也可以说,读取一个值,叫做取它的右值
右值:读出操作,位于赋值运算右边的常量、变量或表达式
我们也可以说,改变一个值,叫做访问它的左值。
从上面的定义可以看出,有些表达式是可以充当左值来继续改变,而大多数表达式只能当右值只读使用。
比如赋值表达式a = b。
赋值运算最后就是改变左值变量a以新的值。也就是说a = b这个表达式,表达式的结果就是a的更新后的值。那么继续改变,让( a = b ) = c;显然是可以的,其实就是a先变化赋值为b值,接着又改变为拷贝c的值。所以( a = b )是可以当左值的。
再比如加法运算a + b,显然这个结果是一个常数,不可能让( a + b ) 再改变为c,也就是说( a + b ) = c会编译错误。
再比如前自增运算++i;实际上是将i自增1后返回增1之后的值——表达式的值是i自增1之后的值,也就是i的新值。++i = a;就表示将i变量最后还是改到a值,尽管之前自增了1。(注意运算顺序,单目运算符优先级很高,仅次于括号;赋值运算符优先级很低,仅高于逗号表达式,因此这里会先运算++i获得自增后的i,然后再将i赋值给以a值)。所以,前自增表达式可以做左值。
后自增不能做左值表达式,这是为什么,同学们想想!
2、赋值兼容性检查
赋值其实就是复制右边的值,拷贝到左边新变量空间,类似于我们的复制、粘贴操作。粘贴的地方是一个新空间,复制的地方则是另一个空间。那么a = b来说,a/b显然不是同一个变量。
我们首先必须明确地址的概念是什么!
每个变量、函数、常量,都需要地方存储,都需要房间,而房间一多起来,就需要编号码。所以,地址,就是房间号。我们一般只考察变量的房间和房间号,对于函数和常量,尽管它们也都需要房间和房间号,但一般不去深究。我们把变量的房间号换一个标准名词,就是地址。由于一个房间不一定能放得下这个变量(不同类型的变量大小不同),而每个房间都有一个房间号,所以还有一个首房间号(首地址)的概念。比如一个int在32bit编译环境下就是32bit,也就是4个字节byte大小,那么一个int变量就占据了连续的4个房间,我们把第一个房间号记下来,就知道了这个int变量的存储区间:是从这个首地址表示的房间号开始的连续四个房间。
现在回到赋值语句。什么样的变量能够相互赋值?能不能一个整型赋值给一个浮点?一个浮点赋值给一个char字符?显然这里需要一个编译检查的规则。违反了规则,就会编译错误。
课件和教材中给出了四条编译器认定为合法的规则:
1)同型赋值
完全相同的类型的两个变量可以相互赋值。例如
int a = 1;//利用常量整型1初始化整型变量a
int b =a ;//利用整型变量a初始化整型变量b
这里两行都是初始化定义,同时也包含了赋值。下面的则稍有不同:
int a = 1, b;
b = a; //使用整型变量a赋值给整型变量b。
注意第一行,利用逗号表达式,定义的b实际上是一个默认值(垃圾值),一般我们认为是未确定值——就好比新分配一个房间给变量,房间没有打扫卫生一样,都有残留的无意义的值。当我们正式使用该变量时一般需要再次用赋值去改变它,所以才有第二句。
由此,我们也有一个小结论:任意类型变量,在定义时都会获得一个初始值。这个初始值可以显式用赋值给定(例如int a = 1),也可以先用默认值(垃圾值,或者说未确定值,,例如int b;),后面要使用时再用赋值改变(例如 b = a; )
2)简单相容赋值
1)中列举的都是完全相同的类型,实际中还会发生简单类型间的相容赋值,我们也称为默认类型转换。
例如:int a = '0';
这里左值是int变量,右值是字符char型常量,显然,a得到的实际上是0的ASCII码,也就是数字48。
(ASCII码,用于存储表示所有的字符,每个字符用一个数字表示,0-255范围)。
再比如:
float b = a; 整型a赋值到更大的单精度浮点类型变量b
int a = 5.67; 双精度浮点double类型常数5.67被截整数位给整型a ,这里发生“截整”导致的数据失真丢失。
所以,简单相容类型可能会发生数据丢失。
3)void*通用指针相容赋值
void*指针表示无类型指针。前面说过,每个变量都需要分配若干个房间号,我们除了要记住每个变量的首房间号,还要知道要几个连续房间里面的数据按照怎样的数组规则来识别出变量数据。
下面举一个例子来描述这个问题。
比如char c = '0',给c变量分配房间存储下来就是ASCII数字48,char型大小是两个字节共16位来存储,但实际上首房间的8bit就够了(8bit能表达最大的无符号整数是255),那么首房间里面存储了二进制的48,连续的隔壁则放二进制0。
现在来了一个服务员,他要打扫这几个连续的房间,但不知道数据类型是字符char型,于是就读取到了48,按照printf("&d",c);打印出来就是48。于是他在记录操作上写:“今天打扫了XX开始的两个房间,输出的客人数据是48”。但实际上来的客人是字符'0',客人结账邹的时候不乐意了,怎么我的数据是48呢?我是字符'0'啊。
这个问题告诉我们,只知道变量的存储房间连续区间还不够(首地址房间号,连续的若干个房间),还需要知道数据的组装。就像例子中所说,要按照printf("%c", c)就对了,就得到的是字符'0'的数据。printf函数中%c指示变量类型是char字符,而不应十进制数字%d。也就是说地址本身应该有二元性,具有数值和类型两重属性。
既然地址具有二元性,那么存储地址这种数据的对应类型变量(指针类型变量,简称指针变量、指针)就具有二元性。故 int型变量,有对应的int* 指针;A类型变量,有对应的A*指针:都是为了存储和记录对应类型A类型变量的两方面的信息:1首地址;2数据组装规则。当对int*做运算,就知道是对整体四个房间号(假设32bit编译器,int为32bit四个房间存储)的整型数据做运算。
void*指针就只有一元属性,只保留了首房间号这个数值,舍弃了类型。因此,它可以用来存储任意类型的房间号:
如下:
int a; int * pa = &a;
//int*类型指针变量,这里是pa,才能存储int整型变量的房间号,我们称pa指向a
float b ; float *pb = b;
//float*类型指针变量,这里是pb,才能存储float类型变量的房间号,我们称pb指向b
但void*指针不一样,它可以随意指向任意类型变量,也就是随意存储任意类型变量的房间号。
void* ptr = &a ;
ptr = &b;
上面的ptr,既可以指向整型a(存储a变量的房间号,注意a是int整型),又可以指向float类型b(存储b变量的房间号,注意b是float型)
正是这个特性,我们称void*变量左值时,能够接受任意类型的地址做右值给它赋值拷贝。
4)继承相容赋值
这种类型其实是OOP最广泛使用的方式,表达的是一种派生类对象是一个基类对象的朴素观点。比如学生如果继承了人,那么学生就永远是一个人,那么人这个类型的变量,就可以做左值,来接收学生做右值来赋值给它。
基类左值,派生类右值
Base(人)
D1(学生)
D2(公务员)
D3(大学生)
代码示例如下(部分)
| //class.h class Base // 人 { char* id;//身份证号 }; class D1:public Base //学生 { char* sno;//学号 }; | class D2:public Base //公务员 { char* wPosition;//工作地点 }; class D3:public D1 //大学生 { char* deptName; }; |
上面是class.h的内容,它将被下面的main.cpp包含。
| //main.cpp #include "class.h" void main() { Base b1,b2; b1 = b2; D1 d1,dd1; d1 = dd1; | D2 d2,dd2; d2 = dd2; D3 d3,dd3; d3 = dd3; } |
当编译 b1 = b2; d1 = dd1; d2 = dd2; d3 = dd3; 这四句是,都是左右值类型相同的同型赋值,予以通过。
下面将main函数进行修改如下:
| #include "class.h" void main() { Base b1; D1 d1; b1 = d1; D2 d2; D3 d3; b1 = d2; d1 = d3;
b1 = d3; | Base* pb; pb = &d1; pb = &d2; pb = &d3; D1* pd1; pd1 = &d3; //这两行是指针形式
Base &rb = d1; rb = d2; rb = d3; Base& rd1 = d3; //这两行是引用形式,后面再讲 } |
当编译下面的
b1 = d2;
d1 = d3;
b1 = d3;
这三句时,都是继承相容检查编译通过。
下面展开详解:
a) b1 = d2;
赋值语句自右向左,编译器检查d2的首次出现,也就是d2的声明,发现在main的第四行有D2 d2;是d2的首次出现,从而需要查找D2类型声明,这声明在class.h中已经被本地#include展开。于是右端查明为class D2:public Base 。这句话是D2是一个自定义类型,它是Base的一个继承子类。既然D2是一个Base子类,那么D2类型的变量d2当然可以读出,然后赋值写入给Base类型变量b1。编译通过!
b) d1 = d3;
类似上面的过程,先检查右端,找到d3的首次出现,也就是d3的声明,发现在main中的第五行有D3 d3;是d3的首次出现,进而需要查找D3这个自定义类型的生命,这声明这声明在class.h中已经被本地#include展开。于是右端查明为class D3:public D1 。这句话是说D3是一个自定义类型,它是D1类型的继承子类。既然D3是一个D1子类,那么D3类型的变量d3就当然可以从右读出,向左赋值给一个D1变量d1。
c) b1 = d3;
同理,右端找到D3,发现class.h中class D3:public D1 ,并且还有class D1:public Base ,于是D3 IS A Base。注意这种关系传递。这是传递性推导得出D3类型的变量d3可以做右值,读出后写入赋值给Base类型的变量b1.
继承相容: Base = D1 Base = D2 Base = D3 D1 = D3
上面的赋值形式还可以是指针和引用形式的赋值。
结论:
居于继承树上更为祖先层次的对象(指针,引用),可以存储赋值给它子孙孙层次的对象(指针,引用)。
启发:继承实际上是IS –A关系
在树中位于祖先层次的指针/引用/对象,//左值
能传值给它以子孙对象的地址/对象//右值
A 值传递-值传递时机
如前所述,赋值兼容性规则表达了编译器检查赋值语句两端变量/表达式的类型能否进行。但赋值语句只是值传递发生的原理式,还有除此以外的另外两种形式会发生值传递。值传递时机就是指在哪些情况下会发生值传递。
1)直接赋值
int a = 1, b = a;
2)实形参传递
在被调函数外部,在主调函数内部,调用传入的参数叫实际参数(实参),实际是赋值的右值变量
在被调函数内部,接收传入的参数叫形式参数(形参),实际是赋值的左值变量
简单来说,就是实参变量做右值,拷贝赋值给了形参变量
| int f(int a) //int a = 3 {...} | void main() { int a = f(3); } |
3)return返回
| int f(int a) //int a = 3 { return a;} //int _sysTemp = a; | void main() { int ret = f(3); //int ret = _sysTemp; } |
当调用有参有返回值的函数时,会发生三次值传递:用不同颜色示例如下:
| int f(int a) //int a = b//1 { return a*a; } //2 //int _sysTemp = a*a; | void main() { int b= 3; int ret = f(b); //3 // int ret = _sysTemp; } |
三种颜色分别代表:第1次第2次第3次值传递。
当调用有参有返回值的函数时,会发生三次值传递,分别是实形参传递、return返回、主调函数接收!!