程序员修炼之道:程序设计入门30讲(txt+pdf+epub+mobi电子书下载)


发布时间:2020-06-10 12:27:50

点击下载

作者:吕云翔,傅义

出版社:清华大学出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

程序员修炼之道:程序设计入门30讲

程序员修炼之道:程序设计入门30讲试读:

前言

计算机科学是一门专业性很强的学科,该学科思考问题、解决问题的独特方式将很多初学者拦在了门外。还记得高中刚接触力学的时候,很多题目让笔者望而却步,经过了反复琢磨,笔者才领悟到受力分析这一根本方法的诀窍,在此之后,所有的题目仿佛一下子变得简单了许多。相比物理,计算机的概念显得更为抽象,入门门槛也因此更高。不同的初学者因天赋不同,在入门这一过程中花费的时间长短不一。然而天才毕竟是少数,很多读者在建立计算思维的过程中遭遇重重困难,一部分读者甚至中途放弃。

当笔者在越过了阻碍初学者入门的这道门槛之后,回过头来看那些当初困扰笔者的问题,似乎并没有什么特别难的地方。笔者认为,大部分困难并非在于问题本身,难的是通过这些问题培养计算机独特的思维方式。

我们通过对北京航空航天大学大一大二软件工程专业学生的调研,搜集了他们在学习过程中遇到的困扰他们的问题。本书收录了其中出现频率最高的大部分问题,例如:什么是指针?对象是如何传递的?为什么静态方法不能调用非静态成员?编译和链接阶段发生了什么?等等。本书分为六部分,分别是:入门学堂、内存模型、初窥算法、面向对象、认识程序、编程之道。在入门学堂这一部分中,我们将学习程序的基本概念,掌握编程的基本方法。内存模型部分则涉及计算机体系结构中较为重要的一部分——内存的知识,程序运行背后的内存模型是学习编程所需修炼的内功之一。初窥算法部分则介绍编程中常见的算法与数据结构,这是学习编程所需修炼的又一大内功。面向对象部分介绍当下最常见的软件开发方法。认识程序部分是关于程序设计更多的知识介绍,例如多线程编程、异常处理、输入输出等。编程之道部分介绍了编程之道,这些方法更多地是为了帮助我们写出高质量的代码。

本书共收录了30个常见的问题,我们认为这些问题是极具代表性的,相信大部分的初学者在遇到这些问题的时候都会想看到这些问题最通俗易懂的解答,而这正是我们撰写本书的目的。无论你是初学者还是已经具备了一定的编程能力的学习者,如果你对本书列出的某些问题还存有疑惑,不妨去阅读一下相应的解答,由于每一个问题都相对独立,读者可以挑选感兴趣的问题进行阅读,而不一定按照顺序从头读到尾。我们希望所有的初学者在阅读完本书之后,能对程序形成一个系统而清晰的认识,成功跨越学习编程的第一道门槛,发现编程的乐趣。

本书具有以下几个方面的特点。

目标性强:本书针对刚刚接触编程的计算机、软件工程相关专业的学生,旨在帮助读者建立计算机专业的思考方式,培养程序员的思维方式。书中收集了大部分初学者都会遇到的问题,通过形象生动的语言进行解答,帮助初学者跨越编程的第一道门槛。

问题典型,回答生动:本书采用一问一答的编写形式,行文类似《十万个为什么》。问题选取计算机相关专业学生在初学编程时最容易遇到的典型问题,范围涵盖内存模型、算法与数据结构、程序设计语言等多个方面。回答采用生动形象的语言,以尽可能多的类比让读者轻松理解问题答案。

受众广泛:本书适合刚接触编程的初学者,包括计算机、软件工程专业大一大二的学生以及热爱编程的自学者。本书也适合学习了编程一段时间的读者,帮助其梳理思路,温故知新。

章节独立:由于本书各章节的问题相对独立,读者可以任意选择感兴趣的章节进行阅读,而不一定要按顺序从头读到尾,增强了阅读的灵活性和针对性。

本书的作者为吕云翔、傅义,另外,曾洪立、吕彼佳、姜彦华参与了部分内容的写作与资料整理的工作。

由于我们的水平和能力有限,本书难免有疏漏之处。恳请各位同仁和广大读者给予批评指正,也希望各位能将实践过程中的经验和心得分享给我们(yunxianglu@hotmail.com)。编者2018年3月一、入门学堂1.#include, using namespace std, int main分别是什么意思?我的第一个C程序2.import, public static void main, String[] args分别是什么意思?我的第一个Java程序3.什么是数据类型?4.如何阅读项目源码?5.如何调试程序?1.#include, using namespace std, int main分别是什么意思?我的第一个C程序

本节的目的就是让读者看一看C++程序长什么样,更重要的,我们希望读者能把原来初学时不明白的地方都弄明白。通过本节,读者会对C++有一个大体的认识。本节的知识较为基础,如果对于示例代码1.1没有任何疑问,完全可以跳过本节。如果对Java语言更感兴趣,也可以直接进入下一节。Hello world!

相信每个程序员接触的第一个程序都是“Hello world”,我们要认识的第一个C++程序也不例外。

示例代码1.1 #include #define HELLO_WORLD "Hello world!" using namespace std; int main() { cout<

示例代码1.1的第一行#include 是文件包含指令,该指令的作用是在编译预处理时,将指定源文件的内容复制到当前源文件中,如图1.1所示。以示例代码1.1为例,在该段代码被编译之前,iostream文件内容会被复制到当前文件的起始位置,替代原先的#include 。为什么要在文件的第一行写这样一句指令呢?我们希望在屏幕上打印“Hello world”,就需要用到标准输出cout,这是一个负责程序对外输出的对象,而该对象是在iostream文件中定义的。简单地说,iostream文件为我们提供了输入输出功能。图1.1 文件包含指令作用示意图

读者你也许注意到了,在#include 后面并没有添加分号,所以这一行并不是一条C语句,而是一个预处理指令。预处理指令是编译器在将程序编译为机器语言之前首先会对程序进行的预处理。常见的预处理指令包括文件包含、宏定义和条件编译,接下来我们进一步了解宏定义的概念。宏定义

示例代码1.1的第二行#define HELLO_WORLD "Hello world!"是一条宏定义,该指令的作用是在编译预处理时,将源文件中所有的HELLO_WORLD都替换为"Hello world!",于是示例代码1.1的第6行cout<

很多初学C语言的同学分不清宏定义与const常量的区别。宏定义只是在编译预处理阶段进行替换,并不会在内存中生成对应的变量。而const常量是一个在内存中分配了空间的只读变量。所以这两者有本质上的区别。命名空间

示例代码1.1的第三行using namespace std;表示使用命名空间std。命名空间是指各种标识符的可见范围。C++标准程序库中的所有标识符都被定义在一个std的命名空间中。如果不在示例代码中使用using namespace std;这一行语句,想要使代码通过编译,就需要将示例代码第6行的cout<

我们可以将命名空间想象成区号,将类名想象为一个电话号码。由于各省市的电话号码可能重复,就通过在电话号码前面加上区号使得该号码成为一个独一无二的表示。Java中也有类似的机制,包名就如同区号,类名就如同电话号码。main函数

接下来代码进入了主体部分——main函数。main函数是C++程序的入口函数,是程序执行的起点。该函数与其他的函数在形式上没有什么区别,也由返回类型、函数名和函数参数组成。

返回类型:C++规定,main函数返回类型是int型。返回值用于告诉程序的调用者(即操作系统),程序的退出状态。若返回0,则表示程序正常退出,若返回其他非0值,表示程序异常退出,返回其他数字的含义由系统决定。所以在示例代码1.1的第7行,我们定义了语句return 0;用来告诉操作系统,函数正常执行完毕。该返回值并不属于打印到屏幕上的内容,很多初学者在一开始会混淆返回值和标准输出的概念。

函数参数:C++中,main函数一共有以下两种定义方式: int main( ) int main( int argc, char *argv[] )

示例代码1.1采用的是第一种定义方式,即没有函数参数。本章最后的进阶部分给出了采用第二种定义方式的示例代码1.2,并阐述了函数参数所表达的意义。

函数主体:我们通过语句cout<

示例代码1.1的运行结果如图1.2所示。图1.2 示例代码1.1的运行结果进阶

对于main函数的第二种定义方式,argc表示传入main函数的参数的个数,argv[]存放着这些参数,在argv[]的这些参数中,第一个参数是程序的全名。我们提供一个以第二种方式定义main函数的程序,见示例代码1.2。

示例代码1.2 #include using namespace std; int main(int argc, char *argv[]) { cout<

在示例代码1.2的第5行cout<

我们在Visual Studio中设置向main函数传入的参数,第一个参数为Hello,第二个参数为world!,两个参数之间以空格隔开,如图1.3所示。

示例代码1.2的运行结果如图1.4所示。图1.3 main函数参数设置图1.4 示例代码1.2的运行结果

如图1.4所示,程序第一行输出了"Hello world!",即我们向main函数传递的参数。程序第二行输出了程序的全名,即argv[0]。2.import, public static void main, String[] args分别是什么意思?我的第一个Java程序

在第1节中,我们已经认识了第一个C++程序,通过该程序我们在屏幕上打印了“Hello world!”。本节中我们将学习第一个Java程序,通过这一节的学习,读者会初步认识Java的包机制、类定义和main函数。Hello world

在第一个Java程序中,我们要完成的工作仍然是向屏幕输出“Hello world!”。在这里我们故意把打印“Hello world!”的方法变得稍微复杂了些,目的是让读者认识一个更完整的程序。

示例代码2.1package语句

程序的第一行是package语句,该语句的作用是规定当前类属于哪个包。

在Java中,同一个包中存放的类是功能相关的,包机制使得项目代码存放在一个合理有序的组织结构下,便于开发人员管理。

同时,包机制提供了类的多层命名空间,这一点与C++中的命名空间类似,用于解决类的命名冲突。我们也许会遇到类名完全相同的两个类,例如有两个类的类名都是A,这时候不同的包名为这两个类提供了不同的命名空间,我们就能通过包名告诉计算机我们使用的到底是哪个类了。若用电话号码做类比,包名即为区号,类名即为电话号码。包名一般全是小写字母,由一个或多个有意义的单词连缀而成,命名规则是:域名倒写.项目名.模块名.组件名。例如我们会发现有些包以org.apache打头,其对应的域名就是apache.org。import语句

接下来的一行是import语句。我们在编写一个类时,经常会用到其他的类,要正确引用这些类,就需要用import语句进行导入声明。在示例代码2.1中,我们为了使用java.util.List类,定义了import java.util.List;语句。如果不在程序起始处定义import语句,程序中所有用到List类的地方都需要使用该类的全名,这就会使代码显得非常冗长。进阶

一个Java编程高手通常对Java常用包非常熟悉,了解Java提供了哪些包,能够帮助自己知道利用Java可以实现哪些功能,而哪些功能实现起来是较为困难的。Java的常用包如下。

java.lang: Java语言的核心,提供了Java中的各种基础类。

java.util:实用工具包,提供了各种功能。

java.net:提供了网络编程相关的各种类。

java.io:包含了输入输出操作相关的类。

java.sql:包含了数据库编程相关的类。

java.awt:提供了用于构建图形用户界面的类。

感兴趣的读者可以通过阅读源码深入了解Java的包机制。类定义

在定义了package语句和import语句之后,程序进入了主体部分,即对类的定义。当编写一个Java源代码文件时,此文件通常被称为编译单元,每个编译单元都必须有一个扩展名.java,而在编译单元内则至多可以有一个public类,该类的名称必须与文件的名称完全相同,包括大小写在内。

Java中,类(内部类除外)有两种访问权限:(1)public访问权限。可以供所有类访问。(2)默认访问权限。同一个包中的类可以访问该类,即包级访问权限。

在示例代码2.1中,我们定义的类名是Code1,其访问权限是public级别的。如果读者想要了解关于面向对象更深入的知识,可以阅读本书的第四部分。main函数

类似C语言程序,main函数也是Java程序的执行入口。main函数与其他函数在形式上并无差异,也是由返回类型、修饰符、参数等构成的。下面以示例代码2.1为例,介绍main函数的各个组成部分。

返回类型:void。Java程序中的main函数返回值必须为空,不允许为int或其他类型。

访问修饰符:public。为了使得该main函数可以直接被系统调用,必须设置访问修饰符为public。

类修饰符:static。static修饰符表明该函数类静态函数,即函数是属于类的,而不是属于对象的。因为main函数是程序的入口函数,系统是通过类来调用该main函数,而不是通过该类的任何对象来调用该main函数,所以必须设置类修饰符为static。关于静态方法更深入的知识,感兴趣的读者可以阅读本书的第20节。图2.1 main函数参数设置

参数:Java中,main函数的参数是一个String数组。该数组内容是用户在运行程序时设置的。用户可以通过Eclipse的run configuration设置Arguments为Hello world!,如图2.1所示。这一方法类似于第1节中Visual Studio为main函数设置参数的过程。

函数主体:示例代码2.1在main函数中首先生成一个List对象,然后循环遍历main函数参数args,将数组中的每个元素添加到List对象中,最后将List对象直接打印到控制台。如图2.1所示,我们向示例代码2.1的main函数传入的参数是“Hello”和“world!”,所以控制台成功打印出了“Hello world!”,如图2.2所示。由于我们是直接将List对象打印到控制台的,所以输出的字符串包含了中括号,并且在元素之间通过逗号进行了连接。图2.2 示例代码2.1的运行结果3.什么是数据类型?

对初学者来说,理解数据类型可能是一个难题。我们已经知道int代表整数,char代表字符,float代表浮点数,但是这些数据类型在内存中是如何存储的?数据类型对于计算机有什么意义?我们还不是十分清楚。

有些人的观点是,理解数据类型是一个循序渐进的过程,一开始的不理解并不会阻碍初学者打好编程基础,随着编写的代码越来越多,再来学习内存的知识,会更加容易。我也十分认同这种观点,数据类型及内存方面的知识不是一下可以吃透的,但如果读者仍充满了好奇,坚持要理解数据类型,阅读本节也是一个不错的选择。定义

在开始进入本节的学习之前,让我们先来看一下数据类型的定义,尽管定义可能有些枯燥:数据类型在数据结构中的定义是一个值的集合以及定义在这个值集上的一组操作。变量是用来存储值的所在处,它们有名字和数据类型。变量的数据类型决定了如何将代表这些值的位存储到计算机的内存中。在声明变量时也可指定它的数据类型。所有变量都具有数据类型,以决定能够存储哪种数据。位、字节、字

从数据类型的定义可以看出,数据类型解决的是变量存储的问题。要理解数据类型,首先需要了解计算机是如何存储数据的。我们都知道,计算机的世界是二进制的,仅仅用0和1就构建了所有的表达,计算机用来存储0或者1的单位是位(bit),8个位组成一个字节(byte),位和字节的定义如图3.1所示。图3.1 位和字节

位:表示二进制位。位是计算机内部数据存储的最小单位,一个二进制位只可以表示0和1两种状态;两个二进制位可以表示00、01、10、11四种状态;三个二进制位可以表示八种状态。

字节:计算机中数据存储的基本单位。一个字节由8个二进制位构成。八位二进制数最小为00000000,最大为11111111,可以表示256种状态;通常一个字节可以存入一个ASCII码,两个字节可以存放一个汉字国标码。

字:在计算机中,一个固定长度的位组作为一个整体来处理或运算,称为一个计算机字,简称字。字通常分为若干个字节,其长度用位数表示,常见的有16位、32位、64位等。字长越长,计算机一次处理的信息位越多、精度越高,字长是衡量计算机性能的一个重要指标。

综上所述,计算机通过对每一位赋予不同的值(0或1)来存储不同的信息,一个字节可以表示256种状态。以ASCII码为例,它一共定义了128个字符的编码,因此只需要占用一个字节的后7位就可以表示所有这128种不同的状态,最前面的1位就统一规定为0。理解数据类型

示例代码3.1

为了理解数据类型,我们首先来阅读示例代码3.1。在该段代码中,首先定义了一个char类型的数组s,该数组共有两个元素,分别是'A'和'B'。C++中,char类型占一个字节,其中,数组第一个元素'A'的ASCII码值为65(对应的二进制表示为01000001),数组第二个元素'B'的ASCII码值为66(对应的二进制表示为01000010),数组的元素在内存中连续存放,因此数组s在内存中的存储方式如图3.2所示。图3.2 数组s在内存中的存储方式

现在我们知道,内存中存在连续的两个字节存放着数组s,这两个字节的内容是01000001和01000010。回到示例代码3.1,在第6行,我们定义了一个短整型指针,指针的值是数组s的地址,也就是数组s第一个元素的地址,在笔者的编译环境中,short类型占两个字节。读者也许还不理解指针是什么意思,接着看示例代码3.1第7行,我们将指针所指的内容打印到屏幕。结合看第6行和第7行代码,我们做的其实是将数组s所占用的两个字节的内容看成是存放着一个short类型的值,然后将这个值输出到屏幕,由于笔者运行代码的CPU采用小端序存储,即低地址存放低位值,高地址存放高位值,因此输出的short类型的值为16961(16961=66×256+65)。关于大端序、小端序的知识,读者可以参考本节最后部分。

通过这个例子,读者应该已经发现数据类型的作用了。对于内存中存放的两个字节的内容,计算机可以将其理解为两个char类型的值,也可以将其理解为一个short类型的值,我们需要告诉计算机如何去解析内存中存放的内容,如何去告诉呢?这正是数据类型要完成的任务。当我们定义了一个char类型的变量时,计算机在分配内存给变量的时候同时记住了这片内存中存放的是怎样一类数据。再举一个例子,对于一个字节的内容01000001,计算机可以将其理解为一个byte类型的值,即65,也可以将其理解为一个char类型的值,即'A'。C++中的基本数据类型

在理解了数据类型之后,我们先看一下C++中的基本数据类型。C++中定义了一组表示整数、浮点数、单个字符和布尔值的算术类型,算术类型的存储空间依机器而定。这里的存储空间是指用来表示该类型的二进制位数。C++标准规定了每个算术类型的最小存储空间,但它并不阻止编译器使用更大的存储空间。因为位数不同,这些类型所能表示的最大值和最小值也因机器的不同而有所不同。表3.1列举了C++中各种基本数据类型,包括占用的空间大小(C++标准规定的最小存储空间)以及取值范围。表3.1 C++中的各种基本数据类型Java中的基本数据类型

Java的基本数据类型区别于C++的基本数据类型的地方是,Java中的基本数据类型所占的存储空间是固定的,不会因为机器的不同而不同,这是因为Java程序运行在JVM(Java Virtual Machine)之上,从而使得运行环境与平台无关。

Java基本类型共有8种,可以分为三类,字符类型char,布尔类型boolean以及数值类型byte、short、int、long、float、double。Java中的数值类型不存在无符号的,且取值范围是固定的。实际上,Java中还存在另外一种基本类型void,不过我们无法直接对它们进行操作。表3.2列举了Java中的8种基本数据类型,包括占用的空间大小以及取值范围。大端序与小端序

字节序又称端序、尾序。在计算机科学领域中,字节序是指存放多字节数据的字节的顺序。如果数据都是单字节的,那就无所谓数据内部的字节顺序了;但是对于多字节数据,比如int、double等,就要考虑数据内部字节的顺序了。常见字节序包括以下两种:(1)大端序:数据的高位字节存放在低地址端,低位字节存放在高地址端。(2)小端序:数据的高位字节存放在高地址端,低位字节存放在低地址端。

为了直观地理解字节序,我们以一个long类型的数据为例,查看该数据在不同字节序中存储的方式。现在定义一个long类型数据0x12345678(十六进制表示,关于进制的知识读者可以参考第4节),该数据在内存中一共占用了4字节。

在大端序存储数据的机器中,该数据从内存低地址到内存高地址的四个字节分别存放的值为0x12、0x34、0x56、0x78,如表3.3所示。而在小端序存储数据的机器中,该数据从内存低地址到内存高地址的4字节分别存放的值为0x78、0x56、0x34、0x12,如表3.4所示。表3.3 大端序数据存储方式表3.4 小端序数据存储方式4.如何阅读项目源码?

每一个程序员都有阅读项目源码的经历,很多初学者在第一次阅读项目源码时都会产生一个疑问,面对规模庞大的项目源码,到底应该从何处下手?本节要回答的便是如何阅读源码这个问题。明确目的

在开始之前,我们应该首先明确自己阅读项目源码的目的。有些程序员是需要维护这个项目,例如修正项目中的bug,或是为项目扩展新的功能;有些程序员是需要对这个项目加以利用,避免重复造轮子;有些程序员是为了提高自己的代码质量,而去阅读优秀项目的源码。首先要说明的是,尽管我们将优秀的代码比作文章,但代码毕竟不是小说一样的读物,如果是为了读代码而去读代码,在一个庞大的项目面前,相信很难有人能够坚持下来。因此我们必须要明确自己阅读源码的目的,这样才能有针对性地解决问题,同时,明确的需求也是我们阅读源码的动力来源。阅读方法

阅读源码遵循的一个原则是自顶向下,首先树立对项目的整体认识,然后进入项目的模块乃至函数层面的细节部分。因此,如果拥有项目相关的资料与文档,例如概要设计文档、详细设计文档、测试文档等,阅读源码就可以事半功倍。如果没有项目文档,我们可以首先查看项目的架构,项目中文件夹的划分往往表示模块划分,通过对目录结构的梳理我们也能对项目形成初步认识。另外,当前层级目录中的readme文件也是重要的说明文件。

在阅读了项目相关的资料与文档之后,我们对项目整体有了初步的认识,下面就要开始源码的阅读了。从哪里开始阅读呢?首先需要明确我们关心的模块,找到项目中相关的功能模块,从该模块开始阅读,而不是阅读与所需功能无关的模块。在锁定了模块之后,就要开始寻找程序入口的地方,例如对于C++和Java,入口函数是main函数,找到了程序开始的地方,我们就能顺着程序的主线梳理核心的代码逻辑了。

阅读源代码应该遵循先整体后部分的原则,而不是一头扎入细节,即阅读的方法应类似于广度优先遍历,而不提倡深度优先遍历。程序的主体是层次最高的代码,往往比较简单,调用的函数往往也较少,根据所调用的函数名以及层次关系一般可以确定每一个函数的大致用途。在理解了程序主体的核心逻辑之后,可以依次阅读程序主体调用的层级较低的模块和函数,分层阅读时,需要注意区分系统函数和开发人员编写的函数,注重阅读开发人员编写的函数。在阅读代码的过程中,不能指望阅读一遍即能掌握,反复的阅读可以加深对于代码逻辑的理解。如果程序的逻辑较为复杂,还可以考虑画出函数的调用关系图,变量的变化方式等。编译运行

想要理解代码,只通过阅读是不够的,最好的方式是运行代码,这就需要我们学会调试,对调试的内容感兴趣的读者可以阅读第5节。在阅读代码时,我们可以在关注的地方设置断点,调试程序运行到断点处,查看此时的调用栈以及各变量值的变化情况。单元测试是理解源码的另一个有效渠道,单元测试中的测试用例能够反映代码作者对于测试用例经过程序执行后的期望结果,读者往往可以通过单元测试加深对源码程序逻辑的理解。编码之道

最后,每个程序员在阅读项目源码之后都应该学习其中优秀的代码编写之道,例如对于设计模式的应用,良好的编程习惯等。在之后自己编写代码的过程中,严格要求自己的代码质量,因为我们的代码一定会在将来被其他人或者自己反复阅读。刚入门的程序员往往只将功能的实现放在第一位,而忽略了我们会花费很长时间阅读我们自己写过的代码,忽视代码质量只会让一个项目变得越来越臃肿和耦合,想要再维护就会花费大量的精力。为了避免破窗效应,我们应该在编写项目的最开始就严格要求代码质量,并自始至终贯彻这一原则。5.如何调试程序?

编程遇到bug是令每个程序员头疼的事情,初学者查找bug的一个常见方式就是在代码中添加输出语句,将自己想要观察的变量的值打印到控制台上,尽管这是一个非常原始的方法,但很多同学发现这种方法行之有效之后就养成了用这种方式查找bug的习惯。然而每一次都要添加和删除输出语句的做法实在是非常笨拙,那么到底应该如何进行调试呢?

本节选择Java作为示例语言,选择Eclipse作为集成开发环境介绍调试的方法。即使读者使用的是其他语言,阅读本节同样可以帮助其掌握调试的基本思想。

示例代码5.1设置断点

以下我们将通过示例代码5.1说明如何调试Java代码。示例代码5.1的作用是在控制台输出1~100中所有的素数,main函数遍历1~100,通过调用isPrime函数判断该数是否为素数,如果是素数就打印到控制台。isPrime函数判断一个数是否为素数的方法是,查找该数是否有除了1和自身以外的约数,如果不存在其他约数,则说明该数为素数,函数返回true,否则返回false。

调试的第一步是设置断点,程序运行到断点时就会暂停并且进入调试模式。我们可以在代码中任何自己关心的地方设置断点,设置的方法是在该行代码的左边栏双击,之后左边栏就会出现一个圆点,如图5.1所示,我们在if语句处设置了断点。图5.1 在代码第6行设置断点

当程序运行到第6行if语句处时就会暂停,在此之后想要让程序继续执行需要通过单步调试来实现。而程序一旦暂停之后,我们就能观察特定时刻程序中各个变量的值,这样我们就不需要通过输出语句来观察变量了。如果我们设置的是普通断点,那么程序在第一次运行到该行代码处就会暂停,示例中也就是for循环中的第一次循环。我们还可以设置条件断点,设置的方法是右击断点,选择Breakpoint Properties,如图5.2所示,我们将Hit Count设置为20,这就表示该循环执行到第20次时才会暂停,在此之前的循环不发生暂停,这就是条件断点。图5.2 通过Hit Count设置条件断点

我们也可以不设置Hit Count,而是通过条件来实现同样的作用,使得循环执行到第20次时才暂停,如图5.3所示。勾选Conditional, Suspend when true,并将条件设置为“i == 20”,表示“i == 20”这一条件为真时程序暂停。图5.3 通过Conditional设置条件断点开始调试

在设置完断点之后就可以开始调试了,进入调试的方法是单击调试按钮,或者右击断点,选择Debug as→Java Application。Eclipse就会进入调试模式,如图5.4所示,视图一共被分为5个区域,对应图中的序号,分别如下。图5.4 调试模式视图(1)线程堆栈区域:表示当前线程的堆栈,从中可以看出正在运行的代码与行号,以及整个调用过程。(2)变量视图区域:该区域包括三个视图,变量视图显示当前代码行中所有可以访问的实例变量和局部变量,断点视图显示当前代码的所有断点位置,而在表达式视图中,用户可以对自己感兴趣的一些变量进行观察,也可以增加一些自己设定的表达式对其值进行观察。(3)代码区域:该区域显示程序代码。(4)代码结构区域:该区域显示代码中的各种函数方法。(5)控制台区域:该区域显示控制台信息,用户可以打印内容到控制台。

进入调试后,程序在设置的条件断点(i == 20)处暂停,让我们来观察一下变量视图区域与控制台区域。图5.5显示了变量视图区域,在变量视图中,我们可以观察到for循环中定义的变量i。由于我们设置了条件断点,程序暂停时i为20。图5.6显示了控制台区域,我们可以观察到程序在暂停之前输出了1~20中的所有素数,输出符合我们设置的条件断点。图5.5 进入调试后的变量视图区域图5.6 进入调试后的控制台区域调试方法

程序暂停之后,就需要通过单步调试让程序继续执行下去了,所谓的单步调试,就是每一步只执行程序的一行命令,主要的调试方法如表5.1所示。表5.1 主要的调试方法、快捷键及其含义

接下来通过示例代码5.1讲解上述主要调试方法。首先是Step into,即单步进入,程序暂停后,我们按下快捷键F5,程序就会开始一行一行执行,由于遇到了函数isPrime,单步进入会让我们的执行进入isPrime函数内部,于是程序执行到第13行,如图5.7所示。图5.7 调试从main函数进入isPrime函数内部

接下来可以继续通过F5键单步执行,当程序运行到第17行时,我们可以在变量视图区域发现新增了变量max,其值为4,如图5.8所示。图5.8 进入isPrime函数内部调试时的变量视图区域

当程序运行到第17行时,我们通过F7键执行Step return,这一调试方法的作用是使得当前所在方法直接执行完毕,因此程序将isPrime方法执行完毕后直接返回main函数第6行,如图5.9所示。

刚才我们已经学习了Step into的方法,读者或许在想,调试程序的时候可不可以不进入isPrime方法的内部,而只是在main函数的层面进行单步调试呢?答案是肯定的,这就是Step over。Step over同样是单步执行程序,但是遇到方法不会进入方法内部。示例代码5.1中,在第6六行按下F6键,程序将在执行完isPrime方法后暂停。图5.9 调试从isPrime方法返回main函数内部

Resume和Terminate比较容易理解,Resume表示让程序继续执行,直到下一个断点处才会暂停。Terminate方法表示让程序终止运行。

Drop to frame调试方法比较特别,该方法可以在当前线程的栈帧中回退,可以退回到当前线程的调用开始处。回退时,在需要回退的线程方法上右击,选择Drop to frame。以示例代码5.1为例,当我们通过F5键使程序暂停在isPrime函数中间时,执行Drop to frame则会让调试重新从isPrime函数的起始处执行,所有内存中变量的值都会回退到函数开始的时候。其他调试技巧

在调试过程中,我们可以修改变量的值。还是以示例代码5.1为例,在设置条件断点后,程序暂停,此时变量i的值为20。我们在变量视图区域右击变量i,选择Change Value,就可以修改变量i的值了,如图5.10所示。

调试时,我们还可以随时监测表达式的值,选中代码中的表达式,右击选择Watch,就可以在Expressions视图中看到该表达式的值了。图5.10 在调试过程中修改变量的值二、内存模型6.变量和对象存储在哪里?理解栈和堆7.什么是stackoverflow异常?8.指针究竟是什么?9.Java中的引用与C中的指针有什么区别?10.为什么C++中new之后要delete, Java中却不需要?11.明明是值传递,可对象为什么发生了变化?6.变量和对象存储在哪里?理解栈和堆

我们在编程过程中不断地定义各种类型的变量,在面向对象的语言中,我们还会经常通过new关键字生成对象。通过第3节的学习,我们已经理解了数据类型,但对于这些数据在内存中是如何存储的可能还存有疑问。变量和对象存储在哪里?答案是栈和堆。经常有人直接把内存区分为栈内存和堆内存,这种方法比较粗糙,内存区域的划分实际比这复杂得多,但这种说法可以反映出与变量和对象的分配关系最为密切的内存区域是这两块。通过学习本节,读者会对栈和堆形成深刻的理解,熟悉内存模型是对一个程序员的基本要求,也是非常重要的一个要求。进程地址空间

在学习栈和堆之前,让我们先看一下Linux中的进程地址空间,从而对进程的内存布局有一个全局的认识。图6.1展示了Linux中的进程地址空间。

Linux操作系统的内存分为两大类,一类是内核空间,一类是用户空间,应用程序进程占用的内存在用户空间分配。一个Linux进程的地址空间分为图6.1中显示的几个主要区域。图6.1 Linux中的进程地址空间(1)栈:由操作系统自动分配和释放,用于维护函数调用上下文,存储函数的参数值、局部变量等。使用一级缓存,调用速度较快。(2)堆:应用程序动态分配的内存区域,一般由程序员分配和释放(C/C++),若程序员不释放,程序结束时由系统释放(Java)。使用二级缓存,调用速度较慢。(3)数据段:该内存区域用于存放程序数据,包括未初始化数据段(即均被初始化为0),初始化数据段。(4)代码段:该段数据存放程序代码,具有执行权限,只读。栈内存

栈在数据结构中是一种具有先进后出特点的有序队列,内存中的栈的操作方式类似于数据结构中的栈。将栈的操作方式比作一堆碗碟,我们拥有两种操作方式:可以在当前碗碟的顶部堆放一个新的碗碟,也可以将最顶上的碗碟取出,先堆进去的碗碟在最下面,最后才能取出。因此栈具有先进后出的特点,先入栈的元素后出栈。在碗碟的比喻中,这个栈的扩展方向是朝上的,而在Linux进程地址空间中,栈内存的扩展方向是自顶向下的,如图6.1所示。

想要理解栈内存的工作原理,必须首先了解栈帧(Stack Frame),栈帧保存了一个函数调用的所有相关信息,每一个函数从调用到执行完毕的过程,对应了一个栈帧在栈内存中入栈到出栈的过程。一个栈帧主要包括以下几部分内容:(1)函数参数,该部分存储函数的实参。(2)函数返回地址,前一个栈帧的指针。该部分存储恢复前一个栈帧所必需的数据。(3)函数的局部变量。(4)保存的上下文,即在函数调用前后需要保持不变的寄存器。图6.2 栈帧的结构

图6.2展示了栈帧的结构,一个栈帧维护了一个函数调用的所有信息。一个栈帧维护了两个指针,分别是ebp寄存器和esp寄存器。ebp是栈帧指针,该值指向了函数栈帧的一个固定位置,不随函数的执行变化。esp是栈帧栈顶指针,始终指向栈顶,会随函数执行不断变化。因此,ebp可以用来唯一标识一个栈帧的位置。在图6.2中可以看到有一个地址保存了旧的ebp的值,该值就是为了让当前被调用函数执行完毕后能够找到调用函数的栈帧,从而找到调用函数的所有相关信息。从ebp正向偏移可以首先看到存放了函数的返回地址,函数的返回地址就是调用完该函数之后要执行的下一条指令的地址。再向上可以看到存放了函数实参。从ebp负向偏移可以看到存放了函数调用前后需要保持不变的寄存器,以及函数的局部变量。

一个函数A的调用及其栈帧形成的过程如下:首先将函数A的参数依次(C语言中依照反向压栈顺序)入栈,接着将当前指令的下一条指令的地址(即函数A的返回地址)入栈,下面就开始执行函数A,依次将函数A的局部变量入栈。当函数A执行完毕,ebp恢复为旧的ebp的值,函数A的栈帧被销毁,此时栈内存栈顶为调用A的函数的栈帧,所以函数的参数和局部变量的作用域仅仅存在于函数内部。本书的第7节有函数调用及其栈帧形成的具体示例,读者可以通过阅读第7节加深对栈内存工作机制的理解。堆内存

由于栈帧的数据在函数返回的时候就被销毁了,函数内部的数据无法被传递到函数外部,仅仅用栈来存储数据是不能满足编程的需求的。因此,堆内存应运而生。

如图6.1所示,堆内存的空间从低地址向高地址扩展,堆的存储空间较栈要大得多。堆内存的空间都是动态分配的,由于大量使用new和delete,堆内存中更容易出现内存碎片。

程序员可以随时在堆内存中申请空间。在C++中,程序员通过new或malloc动态申请堆内存空间,而当程序员不再需要这片内存空间时,需要通过delete或free主动释放这片空间。由于程序员可能忘记释放内存这一操作,因此容易出现内存泄漏的问题,关于内存泄漏的定义读者可以阅读本书第10节。Java针对此问题作了改进,在Java中,程序员通过new申请堆内存空间,而当这一内存空间不再需要时,程序员无须主动释放,Java虚拟机会对堆内存中的对象实施垃圾回收机制,这些不再需要的内存会由Java虚拟机自行回收并得到再次利用。本书第10节还详细介绍了Java中的垃圾回收机制。Java内存分区

接下来我们将学习Java的内存分区,分析Java示例代码中的各个变量和对象分别是如何存储的。

JVM运行时数据区如图6.3所示,JVM运行时会将它所管理的内存划分为若干不同区域,其中,Java堆内存与方法区是由所有线程共享的数据区,而虚拟机栈、本地方法栈、程序计数器是线程隔离的数据区,各线程之间互不影响,各自独立,这些区域是线程私有的内存。图6.3 JVM运行时数据区

Java堆内存是JVM管理的内存中最大的区域,几乎所有的对象实例(通过new生成的对象)和数组都在这里被分配内存。Java堆内存是垃圾收集器管理的主要区域,因此这一区域细分为“新生代”和“老生代”,其中新生代又被进一步划分为Eden区、From Survivor区与To Survivor区。这样划分的目的是为了使JVM能够更好地管理堆内存中的对象,包括内存的分配以及回收。

方法区用于存储类信息、运行时常量、静态变量等。很多程序员将这一区域称为“永久代”,严格说这两者并不等价。这一区域的垃圾回收较少出现,但并非所有数据进入方法区就不会被回收了。运行时常量池是方法区的一部分,该区域用于存放编译期生成的各种字面量和符号引用。

程序计数器是一片较小的内存空间,该区域记录正在执行的虚拟

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载