计算机系统概论十六章.docx
- 文档编号:23601563
- 上传时间:2023-05-18
- 格式:DOCX
- 页数:18
- 大小:30.20KB
计算机系统概论十六章.docx
《计算机系统概论十六章.docx》由会员分享,可在线阅读,更多相关《计算机系统概论十六章.docx(18页珍藏版)》请在冰豆网上搜索。
计算机系统概论十六章
第十六章指针和数组
16.1介绍
在这一章,我们介绍(实际上是重新介绍)两个简单但是功能强大的程序设计概念:
指针和数组。
在我们写LC-3的汇编代码时,我们已经使用了指针和数组。
现在,我们使用C语言来研究它们。
指针就是一个如变量之类的存储对象的地址。
使用指针,我们可以间接地访问这些提供了非常有用的功能的对象。
例如,使用指针,我们可以构建一个修改被调用者传递的参数的函数。
使用指针,我们可以构建一些复杂的在程序执行过程中可以增长和缩短的数据组织。
数组是在存储器中被连续排列的一列数据。
例如,在本书前半部分的LC-3的几个例子中,我们把一个字符文档表示成在存储器中连续排列的字符序列。
这些连续排列的字符被称为字符数组。
为了访问数组中的一个特定项,我们需要指明我们要访问的是哪个元素。
正如我们即将看到的,一个像a[4]这样的表达式将访问名为a的数组中的第五个元素——因为我们从元素0开始为数组计数,所以它是第五个元素。
数组很有用,因为它们允许我们方便地处理一组数据,诸如向量、矩阵、列表和字符串,这些都自然的表示了现实世界的某些特定的对象。
16.2指针
我们以一个经典的例子来讨论指针的用途。
在图16.1所示的C程序中,函数Swap被设计用来交换它的两个参数的值。
函数Swap被main函数调用,它的变元ValueA在这个程序中的值是3,变元ValueB的值为4。
一旦Swap将控制返回给main,我们期望ValueA和ValueB的值被交换。
但是,编译并执行这段代码,你会发现传给Swap的两个变元仍然保留着原来的值。
我们来检查一下在Swap函数执行时的运行时栈,分析一下这到底是为什么。
图16.2显示的是函数完成之前,就在第25行的语句执行之后,但是控制还没有返回到main函数之前的运行时栈的状态。
注意,Swap函数已经在它自己的活动记录中修改了参数firstValue和secondValue的局部副本的值。
当Swap完成并将控制返回给main时,当Swap的活动记录从栈中弹出时,这些被修改的值就被丢掉了。
main中的两个值没有被交换。
我们的程序出现了错误。
在C中,变元总是以值的形式从调用函数传递到被调用函数。
C将出现在函数调用中的每个变元都作为表达式来计算,然后将表达式的值压入运行时栈中,以便将它们传递给被调用函数。
Swap要修改调用者传递给它的变元,它就必须访问调用函数的活动记录——为了修改变元的值,它必须访问它们存储的单元。
Swap函数需要main中的ValueA和ValueB的地址,以便改变它们的值。
正如我们将在下面几节看到的一样,指针及其相关运算使这些成为可能。
16.2.1声明指针变量
一个指针变量包含了一个存储对象的地址,比如一个变量的地址。
一个指针指向了它包含的地址中的变量。
与指针变量有关的是它所指的对象的类型。
所以,比如,一个整数指针变量指向一个整数变量。
为了用C声明一个指针变量,我们使用下面的语法:
int*ptr;
这里,我们声明了一个名为ptr的变量,它指向一个整数。
星号(*)表明后面的标识符是一个指针变量。
C程序员经常说ptr是int星号类型。
类似的,我们可以声明
char*cp;
double*dp;
变量cp指向一个字符,dp指向一个双精度浮点数。
指针变量的初始化方式与所有其他变量类似。
如果一个指针变量被声明为一个局部变量,它就不会被自动初始化。
使用*来声明指针变量的语法可能起初看起来有点奇怪,但是一旦我们学习了指针运算符,这种语法后面的基本原理就会变得更清楚。
16.2.2指针运算符
C有两种与指针操作有关的运算符,地址运算符“&”和间接运算符“*”。
地址运算符&
地址运算符,它的符号是&,生成了它的操作数的存储地址,操作数必须是像一个变量那样的存储对象。
在下面的代码序列中,指针变量ptr将指向整数变量object。
第二个赋值语句的右手边的表达式生成了object的存储地址。
intobject;
int*ptr;
object=4;
ptr=&object;
让我们查看一下这个序列的LC-3代码。
两个声明的变量都是局部的,被分配到栈中。
回忆栈底指针R5指向第一个被声明的局部变量,也就是这个例子中的object。
ANDR0,R0,#0;清空R0
ADDR0,R0,#4;R0=4
STRR0,R5,#0;object=4;
ADDR0,R5,#0;生成object的存储地址
STRR0,R5,#-1;ptr=&object;
图16.3显示了在语句ptr=&object;被执行以后,包含这段代码的函数的活动记录。
为了让事情更具体,每个存储单元都标记了一个地址,这些地址是我们任意在xEFF0范围内选择的。
栈底指针R5当前指向xEFF2。
注意,object中包含了整数值4,prt中包含了object的存储地址。
间接运算符*
第二种指针运算符称为间接或者解除引用运算符,它的符号是星号,*(在这里发音为star)。
这个运算符允许我们间接操作一个存储对象里的值。
例如,表达式*ptr指的是被指针变量ptr指向的值。
回忆前面的例子:
*ptr指的是存储在变量object中的值。
这里,*ptr和object可以互换使用。
添加到前面的C代码的例子中,
*ptr=*ptr+1;
本质上,*ptr=*ptr+1;是object=object+1;的另一种方式。
就像我们所见过的其他类型的变量,*ptr根据它出现在赋值运算符的哪一边来表示不同的意思。
在赋值运算符的右边,它指的是出现在那个单元中的值(这个例子中是数值4)。
在赋值运算符的左边,它指明了要作修改的单元(这个例子中是object的地址)。
让我们查看一下前面代码中的最后一条语句的LC-3代码。
注意,这段代码不同于假如最后一条C语句是object=object+1;而生成的代码。
使用指针的间接引用,编译器为右边的间接运算符生成2条LDR指令,一个用来加载包含在ptr中的存储地址,另一个用来取出存在那个地址中的值。
对左边的间接引用,编译器生成一条STRR1,R0,#0。
如果那条语句变为object=*ptr+1;,编译器将会生成STR R1,R5,#0。
16.2.3使用指针传递引用
使用地址和间接运算符,我们可以修正图16.1中的不能实现两个参数交换的Swap函数。
图16.4列出了一个被称为NewSwap的Swap的修正版本的程序。
我们做的第一处修改是NewSwap的参数不再是整数,而是整数指针(int*)。
这两个参数是要交换的两个变量的存储地址。
在NewSwap的函数体的内部,我们使用间接运算符*获得这些指针所指的值。
现在,当我们从main中调用NewSwap时,我们需要为我们想交换的两个变量提供存储地址,而不是像我们在前面的代码版本中提供变量的值。
为了提供地址,&运算符实现这个目的。
图16.5显示了当NewSwap函数中的不同语句被执行时的运行时栈。
三个子图(A-C)对应于23、24和25行被执行后的运行时栈。
通过设计,C按数值把信息从调用函数传递到被调用函数:
也就是说,计算调用语句中的变元表达式的值,其结果通过运行时栈被传给被调用函数。
然而,在NewSwap中,我们通过使用地址运算符&构建了一个对于变元的按引用的调用。
当按引用传递变元时,其地址被传给被调用函数——为了使其有效,变元必须是变量或其他存储对象(即,必须有个地址)。
被调用函数就能使用间接运算符*来访问(以及修改)其对象的原来的值。
16.2.4空指针
有时我们说指针不指向任何东西很有用。
当我们讨论诸如在19章中的链表的动态数据结构时,你将会对为什么这个概念很有用的原因更加清楚。
从现在开始,我们称那个不指向任何东西的指针叫做空指针。
在C语言中,我们用下面的赋值语句指明这一点:
int*ptr;
ptr=NULL;
这里,我们将NULL的值赋值给指针变量ptr。
在C中,NULL是一个特别定义的预处理宏,它包含一个没有任何指针可以包含的值,除非指针包含空值。
例如,在一个特定的系统中,NULL可能等于0,因为没有一个有效的存储对象可以存储在单元0中。
16.2.5阐明语法
现在我们回顾在11章介绍的一些符号。
既然我们知道如何传递一个引用,让我们重新查看I/O库函数scanf:
scanf("%d",&input);
因为函数scanf需要使用从键盘读入的十进制数值更新变量input,所以scanf需要input的地址而不是它的值。
这样,就需要地址运算符&。
如果我们省略地址运算符,这个程序就会以一个错误结束。
你是否能为发生这个的原因提出一个可能的理由?
为什么没有引用,scanf就不可能正确的工作?
在我们完成对于指针的介绍之前,让我们尝试搞清楚指针声明的语法。
为了声明一个指针变量,我们使用下面的形式声明:
type*ptr;
在这里,type可以是任何预定义(程序定义的)的类型,诸如int、char、double等。
名字ptr可以为任何合法的变量标识符。
使用这个声明,当应用*(解除引用)运算符时,我们便能声明一个type类型的变量。
也就是说,*ptr是type类型的。
我们也能够声明一个返回指针类型的函数(我们为什么这么做将在后面的章节中变的更明显)。
例如,我们能使用形如int*MaxSwap()的声明,声明一个函数。
与所有其他的运算符一样,地址和间接运算符根据C语言中的优先级与结合性规则进行计算。
这些运算符和其他所有运算符的优先级和结合性规则都被列于表12.15中。
注意,两种指针运算符都有很高的优先级。
16.2.6一个包含指针的例子
让我们查看一个包含了指针的例子。
假设我们要编一个程序,给定一个整数除数与一个整数被除数,计算商与余数。
也就是说,程序将计算都是整数的divident/divisor和divident%divisor。
这个程序的结构非常简单,只需要顺序结构——也就是说,不需要重复。
然而,难点是我们想通过一个C函数计算商和除数。
我们可以容易的构建一个函数产生单个输出结果(比如说,商),我们可以使用返回值机制返回调用者。
一个只能计算商的函数由一条语句组成,如returndivident/divisor;。
然而,为了给调用函数提供多个值,我们将使用指针变量,通过引用机制进行调用。
图16.6的代码中包含了实现这个工作的函数。
函数IntDivide接受4个参数,两个是整数类型,另外两个是指向整数的指针。
函数IntDivide用第二个参数y去除第一个参数x。
结果的整数部分被赋值给指针quoPtr所指向的存储单元,而余数部分被赋值给指针remPtr所指向的存储单元。
请注意,函数IntDivide还返回了一个值来指明它的状态:
如果除数为0,它返回一个-1,告诉调用者有错误产生。
否则就返回一个0,告诉调用者计算已无故障完成。
在返回之后,main函数检查返回值来判断商和余数中的值是否正确。
利用返回值来标记在调用者和被调用者之间调用函数时发生的问题,对于传达调用时的错误情况是一种很好的保护性程序设计实践。
16.3数组
考虑一个程序,它保存在一次计算机工程课程考试中50个学生的期末成绩。
最方便的存储这些数据的方法是声明一个单独的对象,比如examScore,我们把50个不同的整数值存储在其中。
我们可以使用一个下标访问这个对象中的某个特定的考试成绩,下标是从这个对象顶部开始的偏移量。
例如,examScore[32]提供了第33个学生的考试成绩(第一个学生的考试成绩存储在examScore[0]中)。
这个例子中的对象examScore就是一个整数数组。
一个数组就是一个类似的数据项的集合,顺序存储在存储器中。
特别的,数组中所有的元素都必须是同一类型的(例如,int,char等等)。
当程序运算的数据被自然的表达为一个连续的数值序列时,数组是最有用的。
因为许多现实世界的数据属于这个类别(比如某门课程的学生考试成绩),数组是有用得令人不可思议的数据结构。
例如,当我们想写一个程序使得接受从键盘输入的100个数字序列,并对它们按升序排列,这时,数组就会是对于在存储器中存储这些数字的自然的选择。
如果我们使用在这之前用过的简单变量来写这个程序几乎是不可能的。
16.3.1数组的声明和使用
首先,让我们看看在C程序中怎样声明一个数组。
和其他变量一样,数组必须有一个相关的类型。
这个类型表明了存储在数组里的数值的属性。
下面是对一个包含10个整数的数组的声明:
intgrid[10];
关键词int表明我们声明的是整数类型的事物。
这个数组的名称是grid。
中括号表明我们声明的是一个数组,10则表示这个数组包含10个将被按顺序放在存储器中的整数。
图16.7显示了grid如何被分配空间的图形表示。
第一个元素,grid[0]被分配在最低的存储地址,而最后一个元素,grid[9]在最高的地址。
如果数组grid是一个局域变量,那么它的存储空间将被分配于运行时栈上。
让我们来看看怎样访问数组中的不同的值。
注意在图16.7中数组的第一个元素实际上编号为0,这表示最后一个元素编号为9。
为了访问某一特定元素,我们在中括号中提供了一个下标,例如:
grid[6]=grid[3]+1;
这条语句读出存储在grid的第四个(记住,我们从0开始编号)元素中的值,把它加上1,并把结果存储进grid的第七个元素中。
让我们看看这个例子的LC-3代码。
我们假设grid是分配在运行时栈中的唯一局域变量。
这就意味着栈底指针R5将指向grid[9]。
ADDR0,R5,#-9;将grid的基址给R0
LDRR1,R0,#3;R1grid[3]
ADDR1,R1,#1;R1grid[3]+1
STRR1,R0,#6;grid[6]=grid[3]+1;
注意到第一条指令计算出数组的基址,也就是grid[0]的地址,然后将其传给R0。
数组的基址通常是数组的第一个元素的地址。
我们可以通过将要访问的元素的下标加上基址,访问数组中的任意元素。
数组的强大功能来自于数组的下标可以是任意的合法的C语言整数表达式这一事实。
下面的例子显示了这点:
grid[x+1]==grid[x]+2;
让我们看看这条语句的LC-3代码。
假设x是被直接分配到运行时栈中的数组grid顶上的局部变量。
LDRR0,R5,#-10;加载x的值
ADDR1,R5,#-9;将grid的基址给R1
ADDR1,R0,R1;计算grid[x]的地址
LDRR2,R1,#0;R2grid[x]
ADDR2,R2,#2;R2grid[x]+2
LDRR0,R5,#-10;加载x的值
ADDR0,R0,#1;R0x+1
ADDR1,R5,#-9;将grid的基址给R1
ADDR1,R0,R1;计算grid[x+1]的地址
STRR2,R1,#0;grid[x+1]=grid[x]+2;
16.3.2使用数组的例子
我们从一个简单的C程序开始,此程序将两个数组的每个对应元素相加,得到两个数组的和。
每个数组表示某门课程中的学生考试成绩列表。
每个数组元素都包含一个学生的成绩。
为了生成每个学生的累积点,我们想执行Total[i]=Exam1[i]+Exam2[i]。
图16.8包含了读出两个10个元素的整数数组,把它们加为另一个10个元素的数组,并打印出这个和的C代码。
一个风格上的注意:
注意表示输入集合大小的常数值的预处理宏NUM_STUDENT的使用。
这是预处理宏的通常的用途,通常它位于源文件的开头(或位于C的头文件中)。
现在,如果我们想增加数组的大小,例如学生注册人数发生变化,我们只需改变宏的定义(一个改变),并重新编译该程序。
如果我们不使用宏,那么改变数组的大小就需要在很多处修改代码。
这些改变可能很难跟踪,忘记一处就可能导致程序无法正确运行。
对于数组的大小,使用预处理宏是好的程序设计实践。
现在来看一个包含数组的更复杂一些的例子。
图16.9列出了一个从键盘输入一个十进制数的序列(总个数为MAX_NUMS),然后判断这个序列中的每个输入的数字重复出现的次数的C程序。
然后,程序输出每个数字,以及它重复的次数。
在这个程序里,我们使用了两个数组,numbers和repeats。
它们被声明为包含MAX_NUMS个整数值。
数组numbers存储输入的序列。
数组repeats在程序中用来计算numbers中的元素在输入序列中重复出现的次数。
例如,如果number[3]等于115,并且在输入序列中共有4个115(即,在数组numbers中有4个115),那么repeats[3]将等于4。
这个程序由3个循环组成,中间的循环实际上是一个由两个循环组成的嵌套循环。
第一个和第三个for循环是从键盘输入和产生程序输出的简单循环。
中间的for循环包含嵌套循环。
代码体判断每个元素在整个输入数组内重复的次数。
外面的循环将变量index从0重复至MAX_NUMS;我们使用index从第一个元素numbers[0]到最后一个元素numbers[MAX_NUMS]扫描整个数组。
内部循环也从0重复至MAX_NUMS,我们使用这个循环再次扫描整个数组,这次是判断与外部循环选择的元素(即,numbers[index])匹配的元素数目。
每检测到重复一次(即,numbers[repIndex]==numbers[index]),数组repeats中的相应元素递加1(即,repeats[index]++)。
16.3.3数组作为参数
在函数之间传递数组是十分有用的,因为它允许我们构建出能对数组进行运算的函数。
假设我们想构建出能计算一个整数数组的平均值和中值的一系列函数。
我们需要
(1)把数组中的所有值从一个函数传递到另一个函数中,或
(2)传递一个数组的引用。
如果数组包含有大量元素,把每个元素从一个活动记录复制到另一个活动记录中可能非常花费执行时间。
幸运的是,C语言本质上是通过引用传递数组的。
图16.10是一个包含有一个函数Average的C程序,Average的一个参数是一个整数数组。
当从main中调用函数Average时,我们传递给它的值与数组标识符numbers有关。
注意,在这里我们没有使用我们在使用数组时经常用的包含有中括号[]的标准符号。
在C语言中,一个数组的名字指的是数组的基址。
名字numbers等价于&numbers[0]。
numbers的类型与int*类似。
它是包含了一个整数的存储单元的地址。
使用numbers作为传递给函数Average的变元,我们就是将数组numbers的地址压入栈中,并传递给函数Average。
在函数Average内部,参数inputValues就被赋值为数组的首地址。
在Average中,我们可以用标准的数组符号来访问原数组中的元素。
图16.11显示了在从Average执行return(程序中的第34行)前的运行时栈。
注意在函数Average的声明中输入参数inputValues是如何被指定的。
括号[]向编译器表明相关的参数将会是特定类型的数组的基址,在这个例子中是一个整数数组。
因为在C语言中,数组是通过引用来传递的,所以一旦被调用函数将控制返回到调用函数,任何在被调用函数中对数组值的修改都是可见的。
如果我们通过数值只传递一个数组中一个元素会是怎样?
如果是通过引用呢?
16.3.4C中的字符串
在C语言中,数组最通用的用途是字符串。
字符串是表示文本的字符序列。
字符串是简单的字符数组,它的每个后面的元素都包含字符串的下一个字符。
例如
charword[10];
声明一个最多可以存储10个字符的数组。
更长的字符串则需要更大的数组。
如果字符串少于10个字符会怎样呢?
在C语言及其他很多现代程序设计语言中,字符串的结尾用ASCII码值为0的空字符标记。
它是标识字符串结尾的一个标记。
那样的字符串也被称为以空结尾的字符串。
'\0'是对应于空字符的特殊序列。
继续我们前面的声明:
charword[10];
word[0]='H';
word[1]='e';
word[2]='l';
word[3]='l';
word[4]='o';
word[5]='\0';
printf("%s",word);
在此,我们为数组的每个元素一一赋值。
数组将包含字符串“Hello”。
注意字符串尾部的字符本身也是占用一个数组元素的字符。
即使这个数组被声明为可以有十个元素,我们必须为空字符留下一个元素,因此比九个字符长的字符串不能被存储在这个数组中。
在这个例子中,我们还使用了一个新的printf格式说明%s。
这个说明从对应的参数所指的字符开始,以字符串末尾字符'\0'结尾,打印出一个字符串。
ANSIC编译器也允许在声明内被初始化。
例如,前面的例子也可以被重新写成如下:
charword[10]="Hello";
printf("%s",word);
要注意两点:
第一,字符串使用双引号""与单个字符区分开来。
单引号用于单个字符,像'A'。
第二,注意,编译器会在字符串的末尾自动加上一个空字符。
字符串的例子
图16.12是对字符串执行一个很简单但是很有用的基本操作的程序:
计算字符串的长度。
由于包含字符串的数组的大小并没有指明字符串的真实长度(然而,它指明了字符串的最大长度),我们需要查看字符串来计算它的长度。
确定字符串长度的算法很简单。
从第一个元素开始,到我们遇到空字符之前,计算出字符的数目。
图16.12的代码中函数StringLength执行这个计算。
注意在scanf语句中我们使用格式说明%s。
这个说明让scanf从键盘上读入一串字符,直到遇到第一个空白字符。
在C中,空格、制表符、新行、回车、垂直制表符,或换页字符都被视为空白。
所以如果用户输入(摘自TheNewColossus,作者EmmaLazarus)
NotlikethebarzengiantofGreekfame,
Withconqueringlimbsastridefromlangtoland;
只有单词Not被存进数组input中。
文本行中余下的被保留,用于后面的scanf来读入。
所以如果我们执行另一个scanf("%s",input),单词like会被存入数组input中。
注意空白是被说明%s自动丢弃的。
我们将在18章,当我们更深入的查看C里的I/O时,我们再更进一步检查这个I/O的行为。
注意单词最大长度是20个字符。
那么如果第一个词更长一些会发生什么呢?
函数scanf并不会检验数组input的大小,而是把字符存储进它提供的数组地址中,直到遇到空白为止。
如果第一个词长度超过20个字符,那么会发生什么呢?
被分配在main函数中的数组input后面的局部变量将会被改写。
画出调用scanf前后的活动记录,看看为什么。
在本章后面的习题中,我们给出了问题,你需要修改这段程序,以便在用户输入的单词长于数组input长度时给出提示。
现在让我们查看一个使用先前的例子中的StringLength函数的更复杂一些的例子。
这个例子列于图16.13中,在例子中,我们使用scanf从键盘中读入一个输入字符串,然后调用一个函数来反转该字符串。
然后,被反转的字符串被显示在输出设备上。
为了正确的反转字符串,函数Reverse执行了两项任务。
首先它使用前面的代码例子中的StringLength函数来判断字符串的长度。
然后它通过交换第一个和最后一个字符,第二个和倒数第二个字符,第三个和倒数第三个字符等等,来执行反转。
为了执行交换,它使
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 计算机系统 概论 十六
![提示](https://static.bdocx.com/images/bang_tan.gif)