写在前面
本文基于人民邮电出版社发行的西尾泰和先生所著《代码之髓》
有一定读书笔记性质,算是精简版改写。
目录
如何深入高效地学习语言
在学习编程语言时,我们经常会有一种感觉
“内容能理解,但感觉不够透彻”
“要学的东西太多了,应该从哪里开始学?”
这一章就是针对这些问题而写。
从比较中学习
当你正在学习一门语言时,你也许会为了找到哪些是重点而苦恼。但如果你正在同时学习其它语言,那么这个问题将迎刃而解——两门语言的共同点就是重点。掌握了这些重点,你就能更快地学习其它语言。
编程语言的教材会给出各种各样的规则,但是它们可能并不是重点,有时候只是会造成你的刻板印象而已。
以真假值为例,众所周知,在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元。这种情况是我们所不愿意看到的。
这种局面被称作“竞态局面”。要出现这种局面,需要满足以下三个条件:
- 两个处理共享变量
- 至少一个处理会修改变量
- 一个处理未完成前,另一个处理可能会进入。
冲突解决
解决这些问题的方法很多,首先是最简单的,不让不同处理共享变量,着也就是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就是下一章继承要讲的功能。
继承与代码再利用
假设我们要开发一款设计游戏,里面有玩家和敌人两种角色。对比发现,二者有相当多的共同点,如果可以让它们共用这些部分就好了。于是就有了继承的概念,在父类中定义共同的部分,由玩家和敌人继承它们,再分别加上各自的特点,这就是继承。
继承有三种实现策略,
- 一般化与专门化。即在父类写上一般共有的内容,子类写专门的内容。
- 共享部分的提取。这种情况下,子类并不是父类的一种。这种模式更类似与在不同函数里提取出公共部分重复利用。
- 差异实现。这种策略的核心在于复用对象里面的方法,但是操作的是新的变量。这种情况下子类也不算是父类的一种。
使用方法多,使得它具有极高的自由度,然而和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