外观
2.4 把L变成参数:值传递、指针与地址传递
2.4.1 为什么要改,以及一次失败的尝试
上一节的六个函数直接操作全局变量L,写起来最省事,但有三个问题。第一,它们只能操作L这一个表,第3章要同时用到好几个栈,这套函数就一个也用不上了。第二,2.2节定义的操作是Insert(L, i, x),表是作为参数传进去的,代码理应和定义长得一样,现在的Insert(i, x)少了一个参数。第三,全局变量谁都能改,主程序完全可以绕过六个函数直接去动L.length,后面要讲的"应用层只通过操作访问数据"就没有约束力。所以要把L从全局变量改成函数的参数。
直接传结构体:一次失败的尝试
最自然的改法是给函数加一个参数,函数体里的L照旧用点号访问:
c
int Insert(SeqList L, int i, char x) {
if (i < 0 || i > L.length) return 0;
if (L.length == MAXSIZE) return 0;
for (int j = L.length; j > i; j--)
L.data[j] = L.data[j - 1];
L.data[i] = x;
L.length++;
return 1;
}调用方写成Insert(L, 9, 'x')。编译通过,运行也不报错,可是插入之后再看,L.length没有变,x也没有进去,表和调用前一模一样。
要弄清楚为什么,得先看看变量在内存里到底是怎么回事。
2.4.2 补充:地址与指针
如果你已经熟悉C语言的指针,可以直接跳到2.4.3。
第1章说过,内存是一排编了号的格子,编号就是地址。这不是比喻。定义一个变量:
c
int a = 10;
printf("%d\n", a);
printf("%llu\n", (unsigned long long)&a);第一行打印10,是a的值;第二行打印一个很大的整数,比如140732784132684,这就是a所在格子的编号。&读作"取地址"。你运行时看到的数字会和这里不同,每次运行也可能变,这没有关系——我们关心的不是具体数值,而是"每个变量都住在一个有编号的格子里"。(%p是打印地址的标准写法,输出十六进制;这里转换成整数打印,是为了让它看起来就是一个编号。)
有了编号,就有了第二种访问变量的办法。除了用名字a,也可以先把它的地址存起来,再顺着地址去找:
c
int *pa = &a;
printf("%d\n", *pa); /* 10 */
*pa = 20;
printf("%d\n", a); /* 20 */int *pa定义了一个存放int变量地址的变量,叫指针。*pa读作"pa指向的那个变量",读它就是读a,改它就是改a。这就是顺着地址找到原件。
再看数组:
c
int b[3] = {1, 2, 3};
printf("%llu %llu %llu\n",
(unsigned long long)&b[0],
(unsigned long long)&b[1],
(unsigned long long)&b[2]);三个地址依次递增,相差正好4,因为一个int占4个字节。这是"相邻格子放相邻元素"的物理证据:数组就是一段连续的格子。既然连续,知道第一个的地址就能算出任何一个的地址:
c
int *pb = &b[0];
printf("%d %d %d\n", *pb, *(pb + 1), *(pb + 2)); /* 1 2 3 */pb + 1不是把编号加1,而是往后挪一个int,也就是加4个字节,指向b[1]。顺序表的Get只要一次计算就能取到第i个元素,原因就在这里。
最后是指向结构体的指针,这正是下面要用的:
c
SeqList L;
SeqList *p = &L;
L.length = 5;
printf("%d %d\n", (*p).length, p->length); /* 5 5 */(*p).length是"p指向的结构体的length成员"。这个写法太啰嗦,C语言提供了等价的简写p->length。后面所有通过指针访问结构体成员的地方,都用箭头。
2.4.3 值传递与地址传递
原因:值传递
回到失败的Insert。在main里和Insert里各打印一次L的地址:
c
/* main 里 */
printf("main: %llu\n", (unsigned long long)&L);
Insert(L, 9, 'x');
/* Insert 里第一行 */
printf("Insert: %llu\n", (unsigned long long)&L);两个数字不一样。也就是说,Insert里的L和main里的L根本不是同一个格子。调用Insert(L, 9, 'x')时,C语言把main里L的内容——整个数组加上length——完整地复制了一份,放进一个新格子,交给函数里的参数L。函数体里所有的修改,改的都是这份复制品;函数一返回,复制品就销毁了,main里的L纹丝不动。这叫值传递。
对int这样的小东西,复制一份没什么。对一个结构体,不但改不到原件,光复制这一百多个字节本身就是浪费——Length、Get这种根本不修改表的操作,每调用一次也要白白复制一遍。
改法:传地址
要在函数里改到原来那个L,就不能传它的复制品,而要告诉函数L住在哪个格子,也就是传L的地址。函数拿到地址,顺着地址去找,找到的就是原件。
参数类型改成指向SeqList的指针SeqList *L,调用时传&L。函数体里L现在是一个地址,访问成员用箭头:
c
int Insert(SeqList *L, int i, char x) {
if (i < 0 || i > L->length) return 0;
if (L->length == MAXSIZE) return 0;
for (int j = L->length; j > i; j--)
L->data[j] = L->data[j - 1];
L->data[i] = x;
L->length++;
return 1;
}对比一下两个版本,函数体只有两处变化:参数类型多了一个*,L.全部变成L->。调用方变成Insert(&L, 9, 'x')。再做一次刚才的打印,这回在Insert里打印的是(unsigned long long)L(L本身就是地址,不用再取),和main里&L的数字相同——找到的是原件,插入生效了。
只读的操作也一律传指针,一是避免复制整个结构体,二是六个函数写法统一。
2.4.4 六个操作的完整版本
按同样的方法改完六个函数,以下是完整版本,从现在起本章都用这一套:
c
#define MAXSIZE 100
typedef struct {
char data[MAXSIZE];
int length;
} SeqList;
void Init(SeqList *L) {
L->length = 0;
}
int Length(SeqList *L) {
return L->length;
}
char Get(SeqList *L, int i) {
if (i < 0 || i >= L->length) return '\0';
return L->data[i];
}
int Insert(SeqList *L, int i, char x) {
if (i < 0 || i > L->length) return 0;
if (L->length == MAXSIZE) return 0;
for (int j = L->length; j > i; j--)
L->data[j] = L->data[j - 1];
L->data[i] = x;
L->length++;
return 1;
}
int Delete(SeqList *L, int i) {
if (i < 0 || i >= L->length) return 0;
for (int j = i; j < L->length - 1; j++)
L->data[j] = L->data[j + 1];
L->length--;
return 1;
}
int Locate(SeqList *L, char x) {
for (int i = 0; i < L->length; i++)
if (L->data[i] == x) return i;
return -1;
}全局变量SeqList L;删掉,表由使用者自己定义,比如在main里写SeqList L;,然后Init(&L)。上一节的验证程序照此改一下参数即可。