首页 > 编程语言 >【代码之髓】研究编程语言的核心点——结构化编程、函数、异常、作用域、类型、容器、并发、闭包和面向对象

【代码之髓】研究编程语言的核心点——结构化编程、函数、异常、作用域、类型、容器、并发、闭包和面向对象

时间:2024-05-28 19:04:05浏览次数:12  
标签:闭包 函数 作用域 C++ 结构化编程 使用 错误处理 变量 语言

写在前面

本文基于人民邮电出版社发行的西尾泰和先生所著《代码之髓》

有一定读书笔记性质,算是精简版改写。

目录

如何深入高效地学习语言

在学习编程语言时,我们经常会有一种感觉

“内容能理解,但感觉不够透彻”

“要学的东西太多了,应该从哪里开始学?”

这一章就是针对这些问题而写。

从比较中学习

当你正在学习一门语言时,你也许会为了找到哪些是重点而苦恼。但如果你正在同时学习其它语言,那么这个问题将迎刃而解——两门语言的共同点就是重点。掌握了这些重点,你就能更快地学习其它语言。

编程语言的教材会给出各种各样的规则,但是它们可能并不是重点,有时候只是会造成你的刻板印象而已。

以真假值为例,众所周知,在C语言中,0表示假,1表示真。但是在Ruby中则不同,0和1都是真,只有false和nil为假。再到Java, 只有false为假,true为真。

从历史中学习

学习编程语言的另一个要点是,要理解这门语言是为什么被开发的,才能更好的理解那些特性。例如,有些语言生来就是为了开发大型程序;有些是为了做数学分析;有些是为了开发网页;有些是为了做脚本语言。

我们并不能确定,5年、10年后一门语言是否还有用。但是通过比较和了解历史,抓住到所以语言的同通点,也是非常重要的。这也是书名《代码之髓》的含义。

程序设计语言诞生史

世界上第一条电子计算机ENIAC, 使用的是连接电缆来编程的方法。这种办法相当原始,于是到接下来的EDSAC, 发明了打点纸带编程法。这样比连接电缆方便多了,但我们还是无法看懂,而且编程很麻烦。

后来则出现了许多种现在意义上的编程语言,比如1954年诞生的FORTRAN语言,全程Formula Translating System,公式翻译系统,它可以把注入 X * Y + Z 这样的式子翻译成机器语言。

FORTRAN的设计者John Backus 说过一句很经典的话,“我的大部分成果源自我的懒惰。” 实际上,放眼现在的计算机领域,几乎所有我们正在使用的东西都是前人出于偷懒的目的而发明的(比如我现在写文章正在使用的md文档),因此这句话也可以成为我们日后创新的一个准则。后人将类似的准则总结为程序员三大美德,即“懒惰、急躁和傲慢”。

但是,虽然编程语言们是为了便捷产生的,不同语言也有不同的侧重点。例如,C++语言侧重于运行速度较快,而Python语言则侧重于编程更简单。以及Scheme语言,它的语言规范加起来只有50页(与此同时C++是1300页,这里是日文版页数),目的是为了便于掌握。

语法的诞生

为了让程序能够按理想方法被编译为机器语言,用语法来约束程序员是非常必要的。

对于语法,举个简单的例子,像 1 + 2 * 3 这样的句子,一些语言解释为 1 + ( 2 * 3 ) ,也有的解释为 ( 1 + 2 ) * 3

这些解释方式,就是每种语言的语法。

这里先以FORTH语言为例。在FORTH语言里没有括号,因此,对于 1 + 2 * 3 这样的句子,就需要写成 1 2 3 * +

也就是后缀表达式。这种方法非常原始,而且可以很容易的解释为机器语言。它的设计者也因此说FORTH语言是最简单的计算机语言,因为其几乎没有语法。

而到了LISP语言,出现了括号,从而使得用括号来标示一段意思单元成为可能。在LISP中,1 + 2 * 3 写作 ( + ( * 2 3) 1) ,也即前缀表达式。LISP中大量使用的括号也促生了这张meme图:

在这里插入图片描述

但是这两种方法其实都不太符合人的直觉。于是,FORTRAN(公式翻译系统)带着中缀表达式诞生了。从此,我们可以像现实中写算式一样写代码中的算式,再由编译器去编译成机器语言。

但在引入中缀表达式的同时,类似四则运算优先级等语法也被引入了,语法的复杂性由此提升。

不过,随着类似于这样的语法越来越多,产生冲突的概率也就越高。例如,C++中的 >> 运算符以及 vector<vector<int>>中就同时出现了>>这一组合。在类似的情况下,语法分析器将很难猜出用户到底想要表示什么。一些程序员会通过写成vector<vector<int> > //添加空格来解决问题。

随着语言发展的愈发复杂,类似的不自然的规定将会越来越多。

程序的流程控制

我们很多人都听过一个词,结构化程序。那么,什么是结构化程序?

用最通俗的方法来说,结构化程序其实就是使用了if、while这样的语句。它们设计的初衷是为例使理解结构更简单。

那么,为什么它们会使得程序更简单?下面就举一个例子:

if诞生以前

这里直接用汇编举例了。这里的代码不能运行,只是示例

MAIN:
	cmp  123,456		#比较123和456
	jne  IF_NOT_EQUAL	#如果不一样就跳转(jne是jump not equal的缩写)
	#if内语句

IF_NOT_EQUAL:
	#if后的语句

这里还只有if,如果再加上else,就更难懂了:

MAIN:
	cmp  123,456		#比较123和456
	jle  IF_LESS_EQUAL	#如果不一样就跳转(jle是jump less or equal的缩写)
	# 大于时的操作
	jmp AFTER_CMP

IF_LESS_EQUAL:
	cmp  123,456
	jne  IF_LESS 
	# 等于时的操作
	jmp AFTER_CMP

IF_LESS:
	# 小于时的操作
	jmp AFTER_CMP

AFTER_CMP:
	# 结束if后的操作

而在c语言里,上述表达就很简单了:

if ( 123 > 456 ) {

} else if ( 123 = 456 ) {

} else {

}

这就是为什么说结构化编程能够方便理解。其实c语言里还是保留了像汇编一样操作的goto语句,只不过goto很容易制造bug,所以现在不怎么使用了。

void compare( int x ) {
	if( x <= 0 ) 
		goto NEGATIVE;
	printf("正数");
	goto END;

	NEGATIVE:
		if( x >= 0 ) 
			goto ZERO;
		printf("负数");
		goto END;

	ZERO:
		printf("0");

	END:
		return;
}

总体而言,确实是没有使用if else结构好理解。

while

接下来是while语句。实际上,while语句就是把一连串的if语句捆在一起。例如下面这个例子:

int i=0;
while( i > 0 ) {
	i--;
}

在这个里面,实际就是做了十次if语句。这里也完全可以使用非结构化程序来实现:

int x=10;
LOOP:
	x--;
	if(x>0) 
		goto LOOP;
END_LOOP:
	return;

至于break语句,实际上就是goto END_LOOP而已。

for

提到while,就不能不提到for语句。for语句的功能实际上也和while一样,只是for把对循环变量的操作都集中到了一起。考虑到for循环各位已经耳熟能详了,这里就不再赘述。

值得一提的是for循环的变种foreach. 这种方法则是彻底的把if语句封装起来了,用户只需要关注被遍历的东西。实际上,到了Python语言中,所有for循环都是foreach循环。

ArrayList<Integer> list = new ArrayList<>(1,2,3,4,5,6);

for(int num : list) {
	sout(num);
}

函数

函数,即把代码的一部分视为有机整体,切分出来并单独命名的程序设计机制。

虽然现代的编程语言中,函数是必不可少的一部分,但是没有函数,也可以进行编程。那么函数有什么作用呢?

为什么要有函数

首先,便于理解。通过把实现某个功能的代码全部从主函数中分离出来,整个结构会变得更加清晰。而对于进行团队协作或者接手你的代码的人来说,他们可以先阅读单个函数,再去阅读整体,这比直接阅读没有函数的整个源码要容易的多。

其次,便于再利用。通过把某个功能抽取出来,你就可以在其它地方使用这个功能,而不需要额外编程。

函数的返回

在现代的编程语言里,让函数返回是非常容易的事情,只需要一个return就可以。但是在这句命令的背后,隐藏了很多细节。

在纯机器语言中,并不存在汇编里FUNCTION: 这样的代码片段标识,而只有某行语句的地址。而我们在使用函数时,需要至少两次跳转:第一次是跳转进入函数的第一行语句,第二次是从函数里跳出回到原来的位置。如果在机器语言中不能使用代码片段标识,就需要直接写死跳转地址。例如这是1949年EDSAC的编程方式:

1:将110处跳转目的地改写为3
2:调用函数(跳转至100处)
3:其它命令
......
50:将110处跳转目的地改写为52
51:调用函数(跳转至100处)
52:其它命令
......
100:函数首行命令
......
110:返回

可见,我们每一次跳转前都要手动改写返回地址;而且如果需要在某一行插入代码,那么后面的每一行都需要改写。

这样是非常麻烦的,所以后来出现了一个办法,即另外拿到一块内存,在跳转前,向该内存中写入需要返回的地址。这种方法在处理单个函数调用时是没有问题的,但是如果我们在调用X函数时又调用了Y函数,那么返回地址就会被覆盖掉,也就无法返回了。

在早期FORTRAN里使用的方法是,为每个函数单独存储返回地址,来解决这个问题。不过,如果在程序里使用了递归,那么X函数的返回地址还是会被别的X函数覆盖掉。

在现代编程语言中,解决这个问题的方法是使用栈。每次调用一个函数,就把函数的返回地址等存入栈中。由于栈是后进入的先弹出,这正好与函数的行为一致——后被调用的函数需要先结束。

这也是为什么,如果我们在java中写了一个无限递归的程序,就会报错StackOverflow,即栈溢出。下面每一行报错信息都是栈里面已经存放的函数,由于是同一个函数无限递归,所以每一行信息都是一样的。
在这里插入图片描述

递归

既然说到了递归,那不妨来提一提递归的作用。对于常见的for循环或者while循环,都是有一个固定的终止条件的。

for(int i=0;i<n;i++){}
while(i<n){}

而对于那些未知终止条件的对象,比如树状菜单,这时候就需要用递归去一级一级的处理了。而我们上文说到了,递归是用栈实现的。

实际上,对于大多数用递归的算法,我们都可以绕过递归,直接使用栈或者队列来实现迭代算法。

错误处理

错误处理发展史

对于还在学习编程的大学生而言,写错误处理是一件很痛苦的事情。究其原因,首先,在进入大学后学习的第一门语言通常是C语言,而C语言是不带有错误处理功能的。其次,即使加上错误处理,发生错误以后,还是需要去读错误信息修改bug,和没有错误处理没什么区别。

由于C语言不提供错误处理功能,当程序员想要实现类似效果时,通常时加在返回值里(如出错就返回-1)。但是这样有两个问题,首先是这样并不能囊括所有错误,其次过多像-1这样的错误编号会降低可读性。

此外,考虑长期运行的话,在理想情况下,函数在测试时不会有什么问题。于是,很多程序员就会忘记检查函数的返回值。然而随着项目越来越发展,这个函数突然就出问题了,由于没有加上返回值检查,寻找错误将变得异常困难。

有时候我们希望在发生错误后,能够对结果进行一些处理。然后,程序也许就会变成这样:

int main() {
	if(!func("A")) {
		/* 错误处理 */
		/* 错误处理 */
		/* 错误处理 */
	}
	if(!func("B")) {
		/* 错误处理 */
		/* 错误处理 */
		/* 错误处理 */
	}
	if(!func("C")) {
		/* 错误处理 */
		/* 错误处理 */
		/* 错误处理 */
	}
}

但好在C语言还有goto语句:

int main() {
	if(!func("A")) goto ERROR;
	if(!func("B")) goto ERROR;
	if(!func("C")) goto ERROR;
	return ;
ERROR:
	/* 错误处理 */
	/* 错误处理 */
	/* 错误处理 */
}

这种做法与1964年推出的PL/I语言非常接近。但是别忘了,我们并不推荐使用goto语句。

PL/I相比C,它不需要使用if检查返回值,而是发生异常自动跳转到ERROR. 另外,它还支持自定义异常。这两种功能被现代编程语言所继承。

在C++和Java语言中,引入了try…catch结构。这种结构可以自动捕捉异常,同时还提供了自定义异常的功能。

而这个结构的最后一块砖,finally与90年代初,在Windows NT 3.1中第一次被使用,并在随后被引入Java和Python等语言中。

值得注意的是,在C++中并不存在finally。finally存在的目的在于,如果在try里面制造了什么锁或者申请了内存空间,那么我们应该有一个地方去消除它们。这些事情不论有没有发生错误,都应该被执行,于是就有了无论如何都会被执行的finally块。

而C++的设计者不这么认为。C++使用了RAII(资源获取即初始化)技术,在这一技术中,每个对象都定义有析构函数,当对象结束时,析构函数会自动调用,从而释放内存空间或者锁等。C++的设计者认为这种方法要更为优雅。

不过也有人并不这么认为,他们主张,打开关闭这样的成对操作应该放在一起才更直观,于是又有了以改良C++为目的的D语言。

异常传递

在Java等很多现代编程语言中,提供了异常传递的功能,即将异常传回给调用方。如果调用方也无法处理,还可以继续向上一层传递异常。

这样做听起来是理所当然的,但是大家的意见其实并未统一。虽然这样方便了编码,但也造成了一个问题,就是我们在阅读代码时,一段看起来没有问题的代码,也有可能会抛出异常,而这个异常可能不是它自己产生的,而是调用的函数产生的,甚至是调用的函数调用的函数产生的。

在这种情况下,如果看不到该函数调用的所有代码,就无法察觉其到底可能抛出什么异常。

在Java中,为了预防这一问题,提出了一个主张,即明确声明可能抛出的异常。也就是使用throw 关键字,后接可能抛出的异常。

class Foo {
	void test() throws MyException {
		throw new MyException();
	}
}

class MyException extends Exception {}

名字和作用域

名字

给变量、函数和对象命名,听起来好像是非常正常的事情,然而,在原始的编程语言中却并不支持这一功能。

例如在汇编中,只有ax、bx等寄存器有名字,而如果想要在内存中存数据,就只能使用[1001]这样的地址操作符来表示,程序员有时需要用现实的纸笔来记录哪里存了那些变量。

后来,人们觉得,如果能够给变量起个名字,会更方便一些。于是,命名就诞生了。

动态作用域

我们来看看接下来这段Perl代码

for($i = 0; $i <10; $i++) {
	&shori();
	print "处理", $i, "结束\n";
}

sub shori{
	$i = 0;
}

如果不看下面的shori函数,上面的部分好像只是对i做循环而已。可是,如果我们不小心在循环内对i做了操作,那循环可能就要变成无限循环了。

为了防止这种问题,当时的人有很多想法,比如起尽可能长的变量名等;在团队项目里,还得在变量名里加上自己的名字,甚至实施变量申请制度。但是这样显然过于麻烦了。

早期的编程语言很多都有这种问题,比如C89标准,要求在可执行语句前,先定义所有的变量。在这种时候,作用域的意义就出现了。

我们希望的是,变量可以只在一小块变量里起作用。比如Perl4提供的动态作用域功能:

sub shori{
	local $i;
	$i = 0;
}

这个函数就可以实现,不影响上面调用它的函数里的$i变量。

不过动态作用域有个问题,就是某个函数里有一个动态作用域变量,那么该函数调用的函数里的同名变量也会收到影响。

$x = "global";

sub func{
	local $x = "test";
	&printf();
}

sub printf{
	print "$x"; //输出test而不是global
}

&func();

乍一看没什么问题,正好可以实现传递参数。不过实际用起来,我们会发现有很多变量会被莫名其妙改变,而如果不去翻阅调用方函数,是无法找到问题的。

静态作用域

于是就有了后来的静态作用域。在这里面,每个函数都有自己的一套变量表,和其它函数没有关系。这也是如今大多数编程语言使用的方法。

不过,这样的方法就没有问题了吗?以Python为例,它有三层变量表(专业一些叫做对照表),分别是函数自己的变量表(本地对照表),然后是函数所在文件的变量表(全局对照表),以及整个程序的变量表(内置对照表)。

在我们的想象中,当在函数里找不到时,就应该去文件里找;文件里找不到,就应该去整个程序里找。不过,至少到Python2.0版本,在函数里找不到时,就会直接去整个程序找。这种设计方法导致了很多误解。于是在Python2.1版本中,改成了我们想象中的逐级查找的方法。

另一个问题是,在Python中完全丧失了读取全局变量的功能,它总是会创建一个新的局部变量。直到2006年的Python3.0, 引入了nonlocal关键字,来标记这个变量应该读取全局变量。之所以选这么个奇怪的关键字,是因为它的使用频率最低,因此可以更多地兼容老代码。

在面向对象语言中也借用了类似的概念。例如,public关键字标注的就是全局变量;protect标注的是只能由自己和子类使用的变量,即动态作用域;private标注的则是静态作用域。

类型

类型这个词,要学术化的解释,比较难以理解。通俗来说,就是int float double bool 这样的变量,就是类型。

计算机中,所有变量都是由0和1记录的,但是表达给用户的却是不同的类型。

数位

我们假设一个场景,数字927,需要多少个灯泡来表示?

如果亮1枚灯泡表示1,2枚表示2,那么需要927个灯泡。这样做有些浪费。

而如果我们用十进制来表示,那么只需要3列9行灯泡,每9个灯泡代表一个数位。此时,只需要27枚灯泡。

在电梯等设备上,我们都见过用横竖线来表示的数字。通过7根线的组合,可以表示出10个阿拉伯数字。在这种情况下,只需要21枚灯泡。

而计算机直接登峰造极,用二进制来表示数字,此时只需要12枚灯泡就够了。

二进制好处很多,只有一个问题,对人不太友好。于是又有了八进制和十六进制,把诸如1011 1101这样的东西转换为阿拉伯数字和英文字母,从而改善阅读体验。

小数

上面描述的是,计算机表示整数的方法。而对于小数,在现行IEEE 754中规定了浮点数的格式。

例如,对于一个32位的数字,第一位为符号位,0为正1为负;后23为为数字,基本类似于整数的转换方法(小数部分不太一样);而中间8位是指数,通过它可以算出小数点从数字第零位开始向右移动多少位。由于这种表示方法,小数点是可以移动的,因此也叫浮点数。

类型的展开

真正让类型变得复杂的,是后来的面向对象思想。例如,C语言里,结构体作为类型的集合,也是一种类型;而到了C++, 不仅有变量,甚至包括了函数的类也被算作类型。

发展到了这一步,Java中的接口也成为了类型。接口中不含有变量,甚至没有函数,它们只是约束其它类型应该实现什么样的功能。

到后来还出现了泛型,它们已经不关注具体存的是整数还是浮点数这样的具体类型了,它们只关注对类型做什么操作。但是,它们也是类型。

template<typename T>
class person {
private:
	T age;
	string name;
}

int main() {
	person<int> x;
	person<double> y;
}

动态类型

到目前位置,介绍的都还是静态类型。在很多现代语言中,如Python或者JS, 都使用了动态类型。声明变量时,不需要指出变量类型;当变量的性质改变时,可以自动的修改变量的实际类型。

在Python中,所有变量都是PyObject类型。在PyObject中,就存储着变量实际类型是整数,还是浮点数,或者是字符串。

不过这样也可能造成一些问题,就是有时候会出现一些悄无声息的bug,而且很难追查问题根源。

容器和字符串

在不同语言中,有各种各样的容器,如C的数组,C++的vector、set, Java的HashMap等。所有这些存放多个元素的东西,我们统一称之为容器。

为什么要有各种容器呢?因为它们有不同的长处。vector可以提供自动扩容和较快的操作速度;Set可以提供自动去重;Map可以提供键值对存储等等。

数组、链表和字典

至于底层实现,有数组和链表等等。数组在读取数据上见长,而链表在插入和删除上见长。

初次之外,还有一类容器,叫做字典,或者叫散列表、关联数组,它在查找数据上见长。其本身是一个容量极大的数组,当输入数据时,会有哈希函数将数据转换为整数,这个整数就是其在数组里存储的位置。通过这种形式,可以实现通过名称,而不是下标来查找数据。

你可能会想,如果已经知道名称了,为什么还需要去数组里查?答案是,字典里面存储的是一对由名字和数据组成的内容。通过名字,就可以快速查到对应数据。

但是哈希函数并不能保证生成唯一整数,也就是说不同的数据可能存在同一个下标里。这个时候就需要进行处理了,可以是向后顺延直到找到空位,也可以在此处存储一个链表,在链表中存储一组内容。

在Java常用的容器HashMap中,就采取了存链表的形式。而在Java1.8后,如果某个地方的链表长度超过了8,那么整个散列表就会自动转化为一棵红黑树。

树和链表一样,是一种依赖于指针的数据结构。在一棵经典的二叉树里,每个节点里除了存自己的数据以外,还会存自己左子节点和右字节点的地址。通过无数个这样的节点互相连接,就形成了二叉树。

如果我们保证,一棵树里,节点的左子节点的值比自己小,右子节点的值比自己大,那么就得到了一棵二叉搜索树。在一棵二叉搜索树里查找数据的时间复杂度是O(logn)级别,小于遍历数组的O(n)级别,因此被叫做二叉搜索树。当然,仍然比不过散列表的O(1)级别。

不过,并不是所有的树都是二叉树。例如MySQL里InnoDB引擎使用的B+树,每个节点会有100个左右的子节点。

字符串

字符串即字符并列的结果,实际上一般就是存储字符的数组。

在众多语言中,C语言的字符串较为原始。例如,它的字符串是以"\0"表示结尾,而不像其它语言,在字符串中直接存储字符串的长度。这样做的坏处在于,如果字符串中刚好有\0, 那么字符串就会被提前截断。

另一个问题是,当使用数组来存时,可能会发生把末尾终止符截断的风险。例如如下代码:

int main() {
	int x = 9252;
	char str[3] = "abc";
	char str2[3] = "defg";
	printf("%s\n",str2); 	//输出defabs$$
}

由于终止符被截断了,而这些数组是存在一起了,就导致了其它字符串被一起输出。

C语言的字符串处理起来相对困难,而存储了字符串长度的Pascal语言就好一些。Java同样采用了这种风格,不过Java语言存储的单个字符是16位,而Pascal是8位。

到了Python, 它即支持8位字符,也支持16位字符。当两种字符混用并进行字符串拼接时,会进行自动转换。不过这种自动转换只使用于ASCII字符,使用其它编码就会出现问题。

于是到了Python3, 这种自动转换就被舍弃了,转换时必须显著标明编码方式。

"hello, " + b"Alice".decode("ASCII")

并行处理

在早期的EDSAC等计算机上,一次只能处理一个任务。这种形式对现代人来说几乎不可接受——想象电脑一次只能打开一个应用。

所以,并行处理就出现了,它让多个程序可以同时运行。

不过,在单个CPU里,真正在运行的程序还是只有一个。在人们察觉不到的非常小的间隔里,CPU不断的切换于不同的程序,从而实现了多个程序并行的效果。

要实现交替运行,有两种办法,一种是在合适的时机,自发的进行交替。不过,在很多时候,这个合适的时机并不会出现,而是一种在执行某个程序。

另一种就是抢占式交替,在一定时间后固定进行交替。这种基于非信任的模式的确实现了更有效的交替,不过也有个问题,例如:

假设甲任务正要从账户中取出1000元,向服务器发出了请求,而这时候发生了抢占式交替,B进程进来了,于是系统从B的账户上取走了1000元。这种情况是我们所不愿意看到的。

这种局面被称作“竞态局面”。要出现这种局面,需要满足以下三个条件:

  1. 两个处理共享变量
  2. 至少一个处理会修改变量
  3. 一个处理未完成前,另一个处理可能会进入。

冲突解决

解决这些问题的方法很多,首先是最简单的,不让不同处理共享变量,着也就是UNIX使用的进程理念,它让每个进程都只使用自己的内存空间。不过,这种方式显然有些缺点,所以后来人还要发明出线程这样的“轻量级”进程。

也可以针对第二个条件,让进程不修改变量。不过这样的话,将其设置为变量也就没什么意义了,可以直接当作常量使用。

或者针对第三个条件,也就是现在所说的“加锁”,让自己处理完之前,其它程序无法操纵数据。

锁的使用是很简便的,只需要加锁就可以避免问题。不过还是会有些风险。例如,进程AB都需要操作变量XY, 于是A先对X加锁,B对Y加锁,结果两个进程都在等着对方释放锁,而无法继续运行。这也就是现在说的死锁。

对象与类

关于面向对象到底是什么,不同语言的理解其实不一样。C++的设计者斯特劳斯特卢普认为,“class是让用户自定义类型的功能”,同时,他还认为,“Simula的继承机制是解决问题的关键”。

但是,“面向对象”这个词的发明者艾伦·凯,也是Smalltalk语言的创造者就很不喜欢继承机制,认为所有这些继承系统都让他感到痛苦。

总的来说,对象就是现实世界的模型。比方说,你面前有一大堆石头,“石头”就是对象,而一块石头就是对象的实例。

面向对象的“模型”一词,总的来说就是把变量与函数归结到一起。除了像Java和C++一样使用类来建立模型,还要许多其它做法。

模块

第一种是使用模块,在Perl中将其称为“包”,即把相关联的函数集中到一起。不过现在用Perl语言的人已经不多了,这里就不再赘述。

散列

第二种是使用散列实现,如JavaScript语言。在JS中,所有东西都是键值对存储的,不论是数据还是函数。

function makeCount() {
	return {
		count : 0,
		push : function() {
			this.count++;
			console.log(this.count);
		}
	}
}

闭包

第三种是使用闭包实现。只要你可以定义带有某种状态的函数,那么它就是闭包。JS同样可以实现:

function makeCount() {
	var count = 0,
	function push() {
		count++;
		console.log(count);
	}
	return push;
}

关于什么是闭包,某教材是这样解释的(英文原句翻译):
“之所以叫做闭包,是因为一个包含自由变量的开放表达式,如果与约束环境结合在一起,那么这个就被“关闭”了。”

这么看还是相当抽象。以上面的程序为例,push中使用了count变量,但该变量不是push定义的,于是count被叫做“自由变量”,而push就是“开放表达式”。而makeCount的函数表定义count=0,这就是给它们添加了约束环境。由此,push函数就可以在包内找到变量值,而不需要到外面找,这就叫做闭包。

最后一种,也是现在最常用的,用类实现。

在C++中,类就是用户自定义的类型。斯特劳斯特卢普最早想用"type"这个词,不过后来还是选用了Simula语言中的class. 在最早期的C++, 也就是C with Classed中,类实际上就是结构体。

不过斯特劳斯特卢普还想做出一些改变。在Smalltalk中,调用方法只需要向对象传递一个信息,而至于对象如何回应是由接收方自由决定的。斯特劳斯特卢普还想要让类具有功能说明的作用,也就是声明类里面有哪些方法;如果尝试调用不存在的方法,就无法通过编译。

现在认为,在C++和Java中用的类有三大功能:

  1. 集合体的生成器
  2. 可行操作的的功能说明
  3. 代码再利用的单位。

1和2就是前面介绍过的功能,而3就是下一章继承要讲的功能。

继承与代码再利用

假设我们要开发一款设计游戏,里面有玩家和敌人两种角色。对比发现,二者有相当多的共同点,如果可以让它们共用这些部分就好了。于是就有了继承的概念,在父类中定义共同的部分,由玩家和敌人继承它们,再分别加上各自的特点,这就是继承。

继承有三种实现策略,

  1. 一般化与专门化。即在父类写上一般共有的内容,子类写专门的内容。
  2. 共享部分的提取。这种情况下,子类并不是父类的一种。这种模式更类似与在不同函数里提取出公共部分重复利用。
  3. 差异实现。这种策略的核心在于复用对象里面的方法,但是操作的是新的变量。这种情况下子类也不算是父类的一种。

使用方法多,使得它具有极高的自由度,然而和goto一样,也导致了理解代码变得困难,需要追查非常多层才能找到方法;而且是潜在的bug制造者。

另一个问题是,如果继承层数过多,修改父类的方法将会影响到所有子类,此时修改代码的代价将变得极高。因此一般都要限制继承的层数。

多重继承

有时候,我们在分类文件时会遇到问题,例如一个文件,既可以放入旅行文件夹,也可以放入摄影文件夹。

如果我们可以实现,同时放入两个文件夹,那么问题就解决了。C++中就采取了这样的思路,一个类可以同时继承多个类,并获得它们的变量和函数。

这个想法是很好的,但如果继承的类里面出现了同名的变量或函数,而子类中调用了它们,那么,到底应该调用哪个呢?

在Java中,为了避免这个问题,就直接把多重继承禁止了。但也不是完全没有办法实现,我们可以让子类继承一个类,再在子类中添加另一个为成员变量。这样,就可以通过该成员变量间接拿到另一个类的变量和方法。这种方法叫做委托。

Java中还有一个办法,就是使用接口。Java中不限制实现多少个接口。但是接口里面没有实现,也就是说,继承后,就必须在类里面实现接口。

PHP同样不认可多重继承,于是也引入了接口的概念。不过总的来说,使用接口的语言并不是很多。

避免冲突还有一个办法,就是按照顺序来决定使用哪个类的变量和函数。在Python2.3后,开始使用一种名为C3线性化的方法,即优先搜索子类;在同级的类中优先搜索先书写的类。

class Base(object) :
	x = 'A'

class Derived1(Base) :
	pass

class Derived2(Base) :
	x = 'B'

class Multi(Derived1,Derived2) :
	pass

print Multi.x 	#输出B

还有一种方法,混入式(Mix-in)处理。大致可以理解为,原来BC继承A, C继承BC的结构,被直接拆成C继承ABC。这是一种编程风格,而不是语言的语法。例如,在Python中就存在许多类似这样被拆成最小化的类。通常在Python中,这些类会在名字中加上MinIn来标识。

继承的未来

也有声音认为,继承问题之所以变得复杂,是因为类同时承担了作为全面的创建实例的作用,和作为小的利用单元功能性作用。这两种功能,从根源来说就是冲突的。于是有人认为,可以把作为利用单元的功能拆出来,做成更小的结构,这就是Trait.

Trait最早是在Squeak引入的,同样使用trait的还有Scala. 现在也有越来越多的语言引入了trait. 而其在将来能否替代现有方案,目前来看任是一个未知数。

标签:闭包,函数,作用域,C++,结构化编程,使用,错误处理,变量,语言
From: https://blog.csdn.net/Dianajr/article/details/139272831

相关文章

  • 前端小白必知必会:JavaScript的作用域
    文章导读:AI辅助学习前端,包含入门、进阶、高级部分前端系列内容,当前是JavaScript的部分,瑶琴会持续更新,适合零基础的朋友,已有前端工作经验的可以不看,也可以当作基础知识回顾。这篇文章瑶琴带大家学习 javascript中关于变量作用域的相关知识点。在JavaScript中,变量的作用......
  • Python闭包和装饰器原理
    #Python闭包和装饰器#############闭包##############'''1.一个外层函数,内嵌一个内层函数;2.内层函数使用外层函数的参数;3.外层函数将内层函数作为返回值返回'''#外层函数defouter(msg):#内层函数definner():#内层函数使用外......
  • js的闭包原理——通过引擎的堆栈解析
    有段代码如下:functioncreateCounter(){leti=0;functionincrement(){i++;}functiongetValue(){returni;}return{increment,getValue}}constcounter=createCounter();在这段代码中,运用了函数的3个特点:在函......
  • 类的作用域
    成员函数中变量查找规则成员函数中使用的名字按照如下方式解析:1、首先,在成员函数内查找该名字的声明。只有在函数使用之前出现的声明才被考虑。2、如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。3、如果类内也没找到该名字的声明,在成员函数定义之前......
  • 组策略-处理-作用域
    [组织单元]是GPO的最小应用单元,可以通过作用域(安全筛选/WMI筛选)以实现GPO在[安全组]或[用户]或[设备指标]级别的应用。■安全筛选■WMI筛选创建WMI筛选器。在GPO的[作用域]中进行链接。受[MS16-072]*影响,如果使用组策略安全筛选,需同时要......
  • 函数对象、装饰器、闭包函数
    函数对象Python中一切皆对象【1】可以直接被引用定义一个函数用一个新的变量名来存,用新的变量名来调用【2】可以作为元素被储存功能字典的函数地址【3】函数可以作为参数传递给另一个函数将函数的内存地址作为参数传递【4】函数的返回值可以是函数直接将函数的内存地址返......
  • 作用域
    作用域【一】什么是作用域变量的作用域Python是静态作用域,变量的作用域源于它在代码中的位置在不同的位置,可能有不同的命名空间,命名空间是变量作用域的体现形式【二】一个例子整个电脑系统,硬盘和系统--->前人约定俗成名称空间--->内建 局部 全局存放变量名和变量关......
  • Python闭包函数和计时器
    闭包函数闭包的内部函数中,对外部作用域的变量进行引用闭包无法修改外部函数的局部变量闭包可以保存当前的运行环境#普通方法实现defoutput_student(name,gender,grade=1):print(F"新学期开学啦,学生{name}是{gender},他是{grade}年级学生")output_student('李白'......
  • 闭包
    闭包(Closure)是指函数和其相关的引用环境的组合。在JavaScript中,函数内部可以访问其外部作用域中的变量和函数,当函数内部引用了外部作用域的变量时,就会形成闭包。一个闭包实际上是由一个函数和在该函数创建时所处的词法环境组成的。这意味着函数可以访问定义时的外部作用域中的......
  • Python如何访问闭包中的变量
    你想要扩展函数中的某个闭包,允许它能访问和修改函数的内部变量。解决方案通常,闭包的内部变量对外界是完全隐藏的。但可以编写访问函数,将其作为函数属性绑定到闭包上来实现访问。defsample():n=0#闭包函数deffunc():print('n=',n)#属性n的......