“PHP 是世界上最好的语言——(破音)”
关于编程语言的话题,一直是程序员们的经典话题。几乎每种语言,都有一批近乎宗教狂热般的粉丝。曾经的我,也是其中一份子,现在回想起来,有一部分原因,是由于学习并掌握这门语言的生态,需要付出不小的时间精力成本,所以自然会有“维护”自己的付出的偏见。当我学习并使用的语言越来越多,我却发现很多有意思的事情,于是想聊聊这些发现,也希望能给学习编程语言的读者,一些微薄的帮助。
在我真正开始学习编程之前,我就听说过:“编程需要很好的数学能力”。由于我以前的数学考试成绩不算很好,所以一直都不觉得自己适合搞编程。想不到的是,由于接到一个兼职的工作,需要用到编程能力,从此走上了穿格子衫的码农生涯。
现在回头来看,“编程需要很好的数学能力”的这个认知,我起码犯了两个错误。第一个错误是,我把数学考试成绩等同了自己的数学能力;第二个错误是编程工作是一个具有广泛内容的事情,在很多领域并不需要你掌握很多高级的数学工具。
国内的数学教育,由于高考指挥棒的存在,所以大部分都是为了“解题”而设计的,而真正的数学能力,是抽象思维能力以及想象能力。很多做题高手,可以凭借海量的题目信息,以及高超的记忆力,去考出高分。但是面对需要复杂的逻辑问题,需要自己设计一些逻辑工具去解决问题的时候,往往并不能很好的解决。编程就是需要有抽象的理解能力,并且能通过想象力,在脑海中构建出一系列的概念,并且推理出方案的活动。而在一般的信息管理程序开发领域,我们要用的数学工具,最常见的也只有初中代数而已。如果你还写一点 2D 的游戏,可能会用到一些平面几何知识,如果做一些策略游戏,可能用到一点概率论或者仅仅是排列组合的知识。除此之外,很多高级的数学工具,在编程工作中都并不普遍,起码写个 APP 网购什么的是用不上的。
import math
# 使用勾股定理
a = 3
b = 4
c = math.pow(a*a + b*b, 1/2)
print('勾', a, '股', b, '弦', c)
如果你要开发 3D 游戏,特别是和图形渲染相关,需要学习计算机图形学,还是需要一些数学知识的。但是这类知识,和高考数学成绩,个人感觉关系不是很大。如果要开发机器学习的程序,可能需要对线性代数、微积分有一定的了解,不过就算你不是特别懂这些,也不会让你完全没法从事机器学习的工作。
从基本的数学能力,也就是抽象思维、逻辑推理、想象力这些角度看,编程工作确实和数学关系匪浅;但这并不表示数学考试成绩不好,就不适合编程,也不用因为没学过离散数学或者图论,就觉得自己不能成为优秀的程序员。如果你手上有一个问题,看起来可以用编程解决,完全可以放心大胆的开始。兴趣和需求,才是真正的学习编程的前提条件。
一般情况下,很少人会认为编程和语文有什么关系,最多可能觉得,写写技术文档会用到语文。但是软件开发界有一句名言:
“任何人都能写出计算机能读懂的代码,只有好的程序员,才能写出人能读懂的代码
“There are only two hard things in Computer Science: cache invalidation and naming things (计算机科学中最难的两件事是命名和缓存失效) - Phil Karlton
现在软件开发的核心矛盾,是日益增长的需求变更,和相对落后的开发效率之间的矛盾。解决这个矛盾的基本方法,就是提高代码的可读性。只有这样,才能让代码的修改更快速,才能让更多的人投入到一个软件项目里并行开发。
如果你要写一份程序源代码方便人类理解,清晰准确的注释必不可少,但更重要的是,整份代码的思路是要清晰合理的,是要以方便阅读的角度进行“谋篇布局”的。在具体的代码表达式上,也应该选择更符合人类思维习惯的进行编写;同时对于变量、函数的名字,也需要认真的设计,以确保表达其含义,这就是编程所需的“遣词造句”。
常见的判断流程代码
在我们的语文课程学习中,最常见的课题是:中心思想、段落大意。如果我们能很好的掌握,如何从文字篇章中分析、理解这些含义的技巧,那么我们在编写软件的时候,也可以用同样的技巧用在代码的阅读和编写上。在我看来,语文的水平,就是平庸的程序员和优秀的程序员之间的一个显著差别。
以前,很多编程技术资料、手册都是英文的,所以那个时候,英语水平确实对技术学习有一定的影响,但现在机器翻译水平已经很不错了,相当多的技术学习,完全可以使用母语来开始。当然,有很多“硬核”程序员,坚持要看原版英文书籍和手册,并宣称这才是最好的学习方法,不过在这个信息爆炸的年代,这么做对于自己要求确实也是太高了一点。
尽管英语水平,现在早已不是从事编程工作的门槛了。但是拥有一定的英语能力,还是很有必要的。我曾经见过使用汉语拼音作为变量名和函数名的代码,阅读起来除了很慢以外,而且时不时我还会改错:要知道,中国有很多方言地区,这些地区的人,对于普通话的读音,可是各不相同的,譬如“灰机”、“资识”。与其折腾五花八门的方言拼音,还不如查一下字典用个好点的英文单词。
有的人会说,为啥我们不直接用汉字作为编程的文字呢?事实上这个讨论在网上一直都有,也有使用汉字的编程语言,譬如“易语言”。总体来说,汉字编程有两个比较大的问题,其一是国际化的问题,毕竟编程技术在全球范围内的共享和共建,英语还是最常见的选择;其二是我们手上的是一个英文键盘,从输入效率来说,写英文的效率会比较高。
总体来说,英语水平会影响编程技术的学习和使用,但不是一个核心门槛。相反,如果长期从事编程工作,可能还会提高一定的单词量,因为常常需要查一下字典,为自己辛苦写下的代码,取个“洋气”的名字。
现在电脑已经不是什么高档电器了,甚至很多手机都比电脑要贵。而且一般的编程工作,也无需特别豪华的硬件配置,很多二手的电脑,都完全能胜任很多编程工作。甚至攒硬件自己装一台电脑,也是一个能学到不少知识的过程。
过去很长一段时间,程序开发的工作机会多,收入水平可观,所以吸引了大量的人员投入这个行业。不过根据个人的经验,也有很多人,在真正从事了一段编程工作,都放弃了这种工作。而且本身经济条件越好的,越容易放弃。毕竟,编程工作是一个“严格”的工作:你可能写一篇文章,里面有几个错别字,不太会影响这篇文章的可读性;你可以画一副画,有几笔是画错了的,也不一定被观众发现……然而,你写错了一行代码,首先编译器就会暴跳如雷和你较劲;如果你搞定了编译器,如果有一些隐藏的 BUG,可能让程序运行到一半突然就崩溃了,如果你见过所谓的“Windows 蓝屏”,你就能知道这类问题多么让人烦躁。——一直浸泡在高浓度的“失败”情绪中,而且还提心吊胆的害怕不知道什么时候出现 BUG,这不是一个让人容易接受的工作。
如果一个人既不用担心柴米油盐,又喜爱编程这个工作,这是最好的状态。这样才能真正的去探索软件开发的技巧,而不是天天打听学什么技术,能得到更高薪的岗位。技术的潮流变来变去,如果仅仅是试图赶上风口,是一件很累人的活。所以,归根到底学编程和有钱没钱关系不大,如果只是想混口饭吃,这个工作,可能和其他工作差异不大;如果真的喜爱编程,那么从中也能获得非常大的乐趣。
说到编程语言,C 语言是一个绕不过的话题。一直到今天,这门历史悠久的语言,依然是软件开发中最常见的语言之一。很多人都说,学编程必须要要学 C 语言,但是事实上,不会写 C 语言的程序员也比比皆是。只是简单的学习过一下,没有真正的开发过工程,是不能叫做“懂 C 语言”的。那么,到底 C 语言是不是一定要学的呢?我觉得要学,也可以不学。下面说说我的理由。
大概大家都知道,我们现在用的很多操作系统,譬如 LINUX,都是用 C 语言开发的。那么,是因为 C 语言“特别好”,所以操作系统才用 C 语言开发的吗?我觉得相当大的原因是历史造成的,也就是说,当很多操作系统在第一个版本的开发时,C 语言可能是当时的最好的开发语言。
现在我们说到操作系统,譬如 iOS、安卓、windows,似乎操作系统是一个提供给用户,进行应用程序的安装、卸载、运行的平台软件。有些还会自带一些好用或者不好用的软件。但事实上,操作系统远远不止上面说的这些,甚至可以说,提供给最终用户进行操作的界面,并不是操作系统的核心功能。操作系统的真正的核心功能,是提供对硬件(最主要的是内存、CPU、磁盘)的功能封装和细节屏蔽,简单来说,操作系统的主要用户是应用程序的开发程序员。微软的第一桶金 MS-DOS 系统,全名是 Microsoft Disk Operating System,翻译过来就是“磁盘操作系统”,看起来是不是就特别“硬件”?
由于操作系统,对于大多数的计算机外设,譬如磁盘、网卡、显示卡等,都做了功能封装,这样应用程序开发者就不需要针对硬件去编程,而是只需要使用操作系统提供的编程接口,就可以使用这些外设的能力了。正因为 C 语言是很多操作系统的开发语言,所以很多操作系统都提供了 C 语言的 API。因此很多开发者都选择继续使用 C 语言来开发其他程序了。
在 Linux 上 man epoll
我在使用 JAVA/C#/PHP 等语言的时候,会比较注意能找到什么样的“库”或者“SDK”,因为我的程序可能需要依赖这些“库”。举个例子,我要读写操作系统的“共享内存”,如果我用 C 语言开发程序,我可以直接调用操作系统提供的 C 语言 API,在 LINXU 上就是所谓的“系统调用”;如果我用 Java,就必须要找到 MappedByteBuffer 这个类,并且只能用 mmap 类型的共享内存,至于其他类型的共享内存功能,可能就要再找找有没有人封装过了。如果没有,那你就需要自己写一个符合 JNI 标准的 C 语言程序,封装一下这个功能函数,然后再提供给 Java 调用。——看,这不还是得写一些 C 的代码吗?所以,直接用 C 语言来写应用程序,就可以避免这个麻烦。
刚刚上面提到,JAVA 如果想要调用 C 语言的代码,需要按照 JNI 的规范写一个封装的程序。这个 JNI 规范,全称是 Java Native Interface,是 Java 提供的一个功能,可以调用一切 C 语言编写的库。事实上,绝大多数的语言,都可以调用 C 语言编写的库,甚至在 Go 语言的源码文件里,以注释的形式写的 C 语言源码,都可以被编译运行。而这些语言都能使用 C 语言代码的原因,是因为 C 语言的 ABI 格式,是最广泛被接受的一种 ABI 规范。
ABI 全程是 Application Binary Interfce,意思是应用程序二进制接口。这类接口定义了不同的二进程程序,如何互相调用(链接)。对比于大家更熟悉的 API,全程 Application Programming Interface,这个是提供给程序员编程用的接口。由于 C 语言的历史悠久,所以其他不管什么语言,一开始都会考虑支持 C 语言的 ABI 规范,以便新的语言可以使用大量的现成的 C 语言编写的库。
C 语言还有一个特点是“简单”,这里的“简单”不是说使用起来很简单,而是这门语言定义的内容比较简单。C 语言的关键字非常少,常用的概念只有“变量”和“函数”两种,恰好大多数语言都有这两个概念,所以去对应 C 语言的“变量”和“函数”就非常方便。这对于适配 C 语言库 ABI 接口非常有利。
如果你想写一个框架,或者比较通用的库,你可能会希望更让这些代码运行在各种编程语言环境下,现在来看,几乎只有 C 语言是最合适的。这样就“促使”很多人继续编写 C 语言代码了。
虽然 C 语言的库几乎被所有语言支持调用,但好玩的是,C 语言自己并没有规定这个 ABI 规范。提供这个 ABI 规范的实现代码,往往是编译器开发商做的。所以我们只学会 C 语言的内容,会几乎连编译运行都无法实现,而是需要再学习一门奇怪的知识,名叫《3L》,才能真正让程序运行起来。
所谓的 3L,就是 Link/Load/Library 的意思。这里面的知识,在每个学 C 语言的第一课就能碰到,但要真正掌握它,却往往没有那么容易。举个例子,我们的 C 语言的 hello world 程序往往是这样的:
#include <stdio.h>
int main()
{
printf("Hello, World!");
return 0;
}
这段代码虽然简单,但却有一个值得思考的问题:“printf()
这个函数的代码,到底在什么地方呢?”很多人会说,在 #include <stdio.h>
里面嘛。这个说法对,但不完全对。因为 .h 文件被称为“头文件”,这种文件里面往往只有“声明”而没有定义。也就是说 stdio.h 里面只是 printf() 函数的“形式”,而不包含实现代码。
而上面 printf() 的具体代码,实际上是通过所谓的“链接”,被编译器“放”进你要编译的程序中的。而“链接”的对象,就是一个叫做 /usr/lib64/libc.so.6 的库文件——这个库文件被称为“C语言标准库”。当我们编译 helloworld 程序的时候,就算不写“链接”的命令,编译器也会自动帮我们链接这个库。
$ ldd a.out
linux-vdso.so.1 (0x00007ffc4bfb0000)
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f6b48f24000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f6b48c20000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f6b48a09000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6b4866a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f6b494ab000)
在 Linux 上可以用 ldd 命令查看链接的动态库
然而,如果不是标准库,而是其他的库,就需要我们学习如何使用编译器参数,去指定要链接什么库文件。这里的链接还分动态链接和静态链接,静态链接的意思是,把需要的功能代码打包到最终的可执行文件里面去;而动态链接,则是让可执行文件在运行时,再去加载库文件。动态链接也可以作为一种软件更新的技术:我们可以通过发布和替换动态库(linux 往往是 .so 文件、windows 则是 .dll 文件)来更新一个软件的功能。
由于链接的过程,是由各个编译器软件来实现的,并不是统一在 C 语言的规范里,也没有一个公司或者组织来约束,所以使用不同的编译器,以及使用不同的编译器生成的库的时候,就会出现大量的“兼容”问题。加上 C 语言也没有后来语言的“包依赖管理”的系统,所以计算链接同一个库,如果用的是不同的版本,也可能出现链接错误,这些问题,也是 C 程序员需要经常处理的问题之一。
尽管 ABI 和链接规范有很多问题,但这些确实是我们现在操作系统的真实底层原理。所以当我们没有其他方法的时候(或者不想使用其他方法),我们最后还是有 C 语言这样的一个手段。
从使用硬件的角度来看,大部分编程语言,其语法功能实际上是用来操控“内存”和“CPU”这两种硬件的。C 语言设计两个重要的概念,来抽象和使用这两个硬件,一个概念是“变量”,另外一个是“函数”。这两个来自于数学的概念,被用于计算机编程,对于推动软件开发的进步,起到了非常重要的作用,以至于现代几乎所有编程语言都有这两个概念。但是“借鉴数学概念”用于编程,却并不是完美无缺。
=
号。在 C 语言中,这个符号实际上对内存的读取和写入操作,但在数学上这是一个“相等”的声明。这导致了大量的因为 if (foo = bar)
的 BUG 诞生。PASCAL 语言用 :=
作为赋值符号,可以说是对这种错误的一个纠正。*
号,同时具备“乘法”“声明指针”“解引用”三个含义,具体是什么意思,取决于这个符号写在什么地方。这也是 C 语言代码阅读和学习比较困难的一个原因。#include <stdio.h>
int main ()
{
int var = 20;
int *ip; /* 指针变量的声明,这里的星号表示声明的是一个指针 */
ip = &var; /* 等号表示赋值,把 var 的地址写入 ip 变量的内存中 */
/* 使用指针访问值,这里的星号表示“解引用操作符”,即读取指针指向的内存块内容 */
printf("*ip 变量的值: %d\n", *ip );
return 0;
}
不过话说回来,C 语言有再多的问题,还是比汇编语言更利于人类理解和操作。而对于内存操作的直接和方便,也让程序员们能创造更多有用且高效的通用数据结构,让我们处理复杂问题变得更加简单。因此在追求高性能程序模块的程序员眼里,C 语言依然是不可替代的一种工具。
那么最后来说,C 语言是不是作为程序员,必须要学的语言呢?从开发实践上来说,不是必然要学。很多编程岗位,并不会因为你懂 C 语言就给你躲开工资。但如果你懂这门语言,用这个语言开发过程序,你会有一种接触底层原理的感觉。计算机科学的基本形式,就是层层抽象。而 C 语言,刚好处于擅长形式化的高级语言,和汇编这种硬件操作语言之间。穿透了这层抽象,就能触摸到硬件的层面,从而对计算机科学有更深一层的理解。
在使用 C/C++ 这类需要手工管理内存的语言时,感觉就好像去食堂吃饭:你需要先自己取餐盘,然后把餐盘装上食物,最后吃完后还得把餐盘还回去。如果餐盘太小了,还得多跑几趟多拿几次餐盘。如果我们使用带内存管理的语言,就感觉是在饭店吃饭:只要点好菜,服务员就会端上做好的饭菜,我们不必担心每道菜应该用多大的盘子,吃完也不需要打扫桌子。这就是所谓的内存资源管理工作,餐盘就是内存,我们希望使用的数据,是盘子里的菜,而不想操心盘子。
除了资源管理,我们写的程序现在往往都是“并发”的,譬如多进程或者多线程的。如果没有任何工具,我们是很难控制多段“同时”运行的代码,对同一块内存(变量)的读写结果。可能你想运行 i++,但是这个变量在多个线程同时运行时,可能 i 会被赋值为其他值;如果你把这个变量作为循环判断值,有可能你的线程会陷入死循环……
另外,安全性也是内存管理的重要原因,经典的“栈溢出”程序漏洞,就是由于对内存缺乏管理限制导致的:如果你从文件、网络或者地方读入一段数据,而没有安排足够的内存空间来存放,譬如使用了一个固定长度的数组作为局部变量,那么你就有可能在读取这段数据之后,让你的堆栈里放入一堆未曾预料的数据,而这段数据中的某一块可能正好能覆盖当前函数的返回地址,于是程序就会在执行完本函数后,跳到一个你刚刚读入的数据所决定的程序里,这样你的程序就可能被用来做任何事情。如此危险的漏洞,只是源于一个读入数据的数组没有检查长度而已!
由于编程语言最基本的能力之一,就是操控内存,所以内存管理功能的实现,自然成了很多编程语言的重要课题。
Java 语言给人的感觉特别贴心,贴心到有点烦恼。对于初学者来说,一旦学会了和它“和谐共处”,写起程序来就会感觉非常“稳妥”。但如果你已经有其他一些语言的使用经验,你会有一种被强行套上秋裤的感觉。
相对于 C/C++ 让人眼花缭乱的各种链接错误,JAVA 语言由于对于 CLASSPATH 的错误,就显得简单太多了——尽管 ClassNotFoundExption 还是最常见的问题。事实上你可以把所有经过 javac 编译的文件,都视为动态库;所有你依赖的库,都通过 CLASSPATH 参数去添加,就可以解决问题了。不过,由于在很多 JAVA 框架里面,组织 CLASSPATH 内容的工作,可能被放在各种配置文件里面,所以很多时候我们“学习”的额外内容,是那些框架“造成”的,但本质上也就是 CLASSPATH 而已。
Java 还具有在运行时通过代码下载、加载 .class 文件的能力。这种能力对于动态更新代码,开发诸如边下边玩功能很有意义。这种能力对比纯脚本型语言要复杂一些,但是性能会更高一点。
CLASSPATH 这种机制很方便,唯一的缺点就是,所有的 java 程序,在进程列表里面,都是一串以 java 开头的长长的命令行(里面大部分都是 CLASSPATH 的内容),看起来一点都不像一个正经进程。
我们用 C 语言定义一个变量,我们可以决定变量所占用的内存长度,以及这块内存是在堆上,还是堆栈上;我们还可以决定,数据在变量之间传递的时候,是传递内存地址(指针)还是传递值(复制)。但在 Java 语言里,这些自由统统都不存在:
Java 的数组,包含 String 类型,终于是会自动检测长度了,不会让你写入数据到预期的地址之外了,如果你的程序没注意,Java 会抛出一个 OutOfIndexException。这样“栈溢出”的安全漏洞风险,会大大的降低。虽然一不小心就会碰到这种异常很烦人,但是每个这种异常,放在 C 语言程序里,可能就是一个致命的安全漏洞,修复这种问题还是很有必要的。
当然,Java 是不需要自己回收内存的,因为所有的“对象”都会被 JVM 在运行时进行“垃圾回收”。这个过程我们可以想象,在整个内存池中扫描成千上万的地址,是挺消耗性能的。一般来说我们无法直接参与这个过程,因此也被很多程序员诟病,还想出各种“奇技淫巧”试图影响这个过程。
虽然 Java 有自动的“垃圾回收”机制,但还是有可能出现内存泄露的。如果你使用了一个 static 类型的变量,恰好这个变量又引用了大量的其他对象,譬如说你这个变量是一个 HashMap,这就可能成为一个“内测漏洞”。当然,一个无穷递归也很容引发内存耗尽,这个“栈耗尽”和其他语言是一样的。
任何声明了会抛出异常的方法,你调用它就必须要捕捉这些异常,否则不能通过编译检查。——这个对于初学者来说,简直就是一场和编译器的搏斗。但是这场让人精疲力尽的博斗的结果,还是挺有价值的。绝大多数的错误,都会被强迫处理,以往那些“不判断返回值”而导致的 BUG,在 Java 中是很少出现的。异常处理就好像一个安全围栏,把你的程序保护起来。
FileInputStream in;
try {
in = new FileInputStream(file); // 正常流程就这两行
in.read(filecontent);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try{
in.close();
} catch (IOExeption e) {
e.printStackTrace();
}
}
读个文件,异常处理代码比正常代码要多一大堆
然而,再安全的围栏,也有一些缺口。对于服务器端 Java 程序来说,最常见的有两个:
尽管没有关键字来直接启动线程,但是 synchronized 关键字让 Java 的“并发锁”用起来变得非常容易。JDK 自带的 Thread 类及其相关类库,让编写多线程程序变得非常简单。
不过,对于并发问题的处理,除了多线程以外,单线程异步是一种运行效率更高的方式。因为有可能节省大量的线程栈内存的占用,而且也可以利用到 Linux 的 epoll 能力。java.nio 提供了比较好的支持,不过,对比多线程的支持,异步回到或者“协程”的支持就没有 Go 语言那么好。
Java 的多线程,在 Linux 上还是使用 pthread 库,用子进程来模拟的线程。虽然 Linux 的多进程性能也相当不错,但是在成千上万的“java 线程”的疯狂切换的情况下,对内存和CPU都会造成比较大的压力。这个问题也是后续其他很多语言和框架着眼的地方。譬如 go 语言就会根据 CPU 的核心数来启动真正干活的子进程,而编程概念上的“协程”和真正的子进程是不捆绑的。
C# 就是 Java 异父异母的亲兄弟:两者都号称可以跨平台,也确实做到了windows/linux 双栖;两者都是运行字节码代码,有自己的虚拟机进程;以前觉得 M$ 特别封闭,觉得 SUN 相对开放,现在反过来对比,微软比甲骨文更开放。
C# 好像一个各种语言特性的大杂烩,或者叫博彩众家之长:
+ - * /
运算符好像上面这样的特性还有好多好多。你可以按 Java 类似的特性去写 C#,也可以用 C++ 的想法去写 C#,不知道这是不是这门语言设计者的目的呢?
Go 语言的设计相当的“自我”,它不会去考虑迁就不同“习惯”的程序员,而是直接定死自己觉得好的“规矩”作为默认用法,这和 C# 简直就是一个强烈对比。
new()
指定了,可能也是白忙活)package main
/*
#include <stdio.h>
void c_print(char* str) {
printf("%s\n", str);
}
*/
import "C"
func main() {
s := "hello C"
cs := C.CString(s)
C.c_print(cs)
}
我们常常说,面向对象的三个特征:封装、继承、多态。但是这三个特性,几乎每个特性,都有一堆反对者,认为这样的特性是无效的。
在没有面向对象特性支持的时候,编程语言也可以完成一切逻辑表达。如果我们不把面向对象视为一种信仰,而是一个工具,我们才能发挥它的作用。
面相对象是一种名词性的定义,它希望编程语言不再是动词形式或者数学形式的,而是类似日常人类思维的方式去描述问题。所以才有了把函数和结构体放到一起,成为一种逻辑单元的定义。如果我们已经把一个事情的处理流程,完整的细化分割出来后,其实是不需要面相对象的,这种场景在现存的进存销、运输管理、财务、电信这些现成业务环境下,是很常见的。上面失血模型的支持者,连封装都不想要了,还怕什么继承破坏封装?所以说谈面向对象的时候讨论失血模型,本身可能就是一种错误的面向对象建模导致的问题。
如果不使用继承,即便相似的功能,也必须要定义很多用法类似,但名字不同的函数(库)来提供给程序员。PHP 的库里面就有大量这种例子。学习 API 在这种情况下,成为一种效率比较低的工作。如果你只是开发某个特定的工作细节,这种消耗可能不甚明显,但如果你是某个外包软件公司的程序员,可能你每天都必须不停翻查各种 API 手册。更重要的是,你不能只修改一个库里面的几个函数,然后把一整个库提供给你的同事,而是必须重新写一整套的库,即便库里面大多数代码都是只有“包装代码”——这也是用组合替代继承的常见情况。
关于多态,甚至有一个设计模式,基本上就是多态特性的“使用指南”,这个模式叫“策略模式”。不过,也有一个走火入魔的例子,就是类似早年的 Java Spring 框架,整个程序的初始化,并不是 Java 代码,而是一个巨大而且复杂的 XML 配置文件。所有登记的类都按照一套复杂的规则,实现某一批接口,然后在没有 IDE 和编译器检查的帮助下,试图组合运行起来。事实上,如果你认为多态是一种好的编程特性,那么必然也会认可,降低程序员的心智负担是一个有价值的事情。只不过继承和封装,并不像多态对于复杂逻辑的简化程度,有如此立竿见影的效果。
“面向对象综合征”最典型一个症状就是类爆炸,最常见发病于 Java 领域:在 Java 中,任何东西都要放到一个类里面,就算只是一个 main 函数,也必须要找个类把这个函数包起来,还得加上 static public 修饰方法;用所谓面向接口编程的模式下,往往你为了增加一个方法,被迫新增两个类定义,一个实现类,一个接口类。
如果沉迷于 MVC 的模式,一个功能可能被弄成三组类型:全是结构体属性的 model 大队、全是用于显示的代码的 view 大队、还有不知道为什么一定要有的一堆 control 大队,即便你写了一堆代码,还是发现有一批业务逻辑不知道放哪里,于是又写了一堆 service 类型,用来被 control 或者 model 调用。我们很多时候学习面相对象编程方法,都是向各种框架去学习,但是框架为了通用性,本身就是一个带有大量的接口的程序。所以完全学着某些框架去设计类,或者过于热衷实现某种设计模式,就特别容易搞出大量的类。
面向对象语言一直有一个问题,就是对象构造的过程非常麻烦。所以设计模式里面,有差不多一半是用来构造对象的。在 Java C# Python C++ 等语言里面,都有所谓的对象构造器的设计。但是在本类的各种属性初始化、本类构造器、父类的各种属性初始化、父类的构造器这些代码的顺序上,事情变得异常的复杂,加上构造器还有不同的参数和重载,加上类的静态成员也需要构造。如果类似 C++ 是多继承的语言,这种问题会变得更加复杂。很多编程的面试题,最喜欢考这一类问题,但我却觉得,这种复杂性是编程语言本身的一种缺陷。编程语言是给人用的,不是考人用的。
某种语言的对象构造顺序
在比较新的语言(相对 C++/JAVA)上,很多时候会抛弃“类模板”的设计,就是不再设计一个叫“类”的概念,而是保留“对象”的概念。没有了“类”,就不存在“类爆炸”了。继承的实现,就用简单的“原型链”的思路:A 对象如果是 B 对象的“原型”,那么在 B 对象上找不到的东西(方法或者属性),就顺着原型链往上去找,也就是去 A 对象那里找。JavaScript(TypeScript)、Lua、Go 都是用的原型链,我称之为“基于对象”。使用这种方法,灵活性和代码的编写复杂度,显然是比较小的。在现代 IDE 的帮助下,往往也能获得足够的对象成员提示,不至于太多的编译错误。大部分传统的面向对象设计模式,其实都可以用基于对象的语言来实现,而且“构造类”模式,譬如工厂模式之类的,会比类模板的语言更加简单直观,甚至你都不会意识到在用的写法,曾经就是一种设计模式。
C++ 号称兼容 C 语言,意思是你可以像写 C 语言一样编写 C++ 代码。同时,一般的 C++ 编译器,也能很好的链接 C 写的库。但是,如果不特别的标注 extern "C"
,C++ 写的库是不能被 C 语言代码链接的。C++ 为了在语法上兼容 C 语言,让很多新的特性“嫁接”在 C 语言的概念上。譬如 指针 这个概念,整个面向对象的动态绑定,几乎都利用‘指针’来表达(另外还有 C++ 专属概念‘引用’)。同样的还有 struct
这个关键字,C 语言和 C++ 语言都有这个关键字,但真正的功能可像相差很远。对于 C 语言来说,结构体变量的内存长度、布局其实是比较简单的,但是 C++ 的对象可不简单,而且很多公司面试很喜欢问这个。
class Parent {
public:
int iparent;
Parent ():iparent (10) {}
virtual void f() { cout << " Parent::f()" << endl; }
virtual void g() { cout << " Parent::g()" << endl; }
virtual void h() { cout << " Parent::h()" << endl; }
};
class Child : public Parent {
public:
int ichild;
Child():ichild(100) {}
virtual void f() { cout << "Child::f()" << endl; }
virtual void g_child() { cout << "Child::g_child()" << endl; }
virtual void h_child() { cout << "Child::h_child()" << endl; }
};
class GrandChild : public Child{
public:
int igrandchild;
GrandChild():igrandchild(1000) {}
virtual void f() { cout << "GrandChild::f()" << endl; }
virtual void g_child() { cout << "GrandChild::g_child()" << endl; }
virtual void h_grandchild() { cout << "GrandChild::h_grandchild()" << endl; }
};
上面代码一个 GrandChild 对象的内存结构
C++ 在面向对象的多态上,几乎完全依靠“指针”。由于 C 语言当中,变量的类型决定了变量的内存数据,所以你一旦声明了一个父类变量,这个变量就时固定为父类对象了,再也没有机会用作任何的子类对象变量, C++ 也兼容了这一点,但是如果没有办法拿一个父类变量作为子类变量使用,动态绑定就无从谈起,于是 C++ 就借用了“指针”这个概念:所有类型的指针,内存长度都是一样的。于是 C++ 的整套面向对象的动态绑定(多态)机制,就都建立在指针上了。
Parent *obj = new Child()
如果对于指针搞不明白,不但 C 语言玩不转,C++ 也是基本没法用的。这个糟糕的星号,从 C 语言一直留到 C++。
如果你希望写一套程序库,而且希望约束使用者的用法,那么你除了希望这个库有足够的功能外,肯定也希望编程语言能提供给你一些工具,能够让用户能足够灵活的使用你的库。特别是对于“有一部分”代码,你预期是使用者编写,然后放在你的框架内运行的情况,俗称“回调”,譬如说你写了一个 web 服务器的框架,希望使用者只用填写访问某个 URL 就执行的函数;或者说你写了一个游戏的框架,希望使用者只编写某个角色被击中的效果等等。
这种代码在传统的面向对象变成方法上,一般需要定义一个 interface,然后让使用者来实现。这种扩展方法,也是导致“类爆炸”的原因之一,因为使用者如果使用了多个框架,那么为了使用这些框架而写的回调函数,可能需要定义一大堆 interface。而 C++ 的另外一个特性,就很好的解决了这个问题,这就是“模板”功能。
有的人会认为“模板”特性,几乎是另外一种语言。然而“模板”特性被用在 C++ 最重要的组成部分 STL 里面,已经成为 C++ 这个三位一体语言(C语言、面向对象、模板泛型)不可缺少一部分。所以 C++ 如此的复杂,是因为其实整合了三类特性到一门语言中。“模板”特性虽然复杂,但是用来开发被复用的模块,却有非常大的好处:
对于 STL(Standard Template Library) 来说,很多“类型”只要支持一些数学运算符号,譬如“等号”“大于”“小于”这一类,就可以由 STL 提供大量的数据结构工具(如 List/Map 等等),这让这个库成为应用最广泛的 C++ 库。
template <typename T>
void const& DoSomething (T const& a)
{
a.DoSomething();
}
上面代码中的模板函数 DoSomething()
,可以接受任何类型实现了一个叫 DoSomething()
方法的对象。这是不是比面向对象写法中,强迫用户一定要“先声明一个含有DoSomething
的接口,然后让要用的类型实现这个接口”,要简单的多?
在MOBA类游戏中有一句话流传甚广,叫做“没有最强的英雄,只有最强的玩家”,这句话被许多玩家奉为经典。编程语言也是这样,好的程序员往往会精通好几门语言,并且在合适的情况下选择合适的语言,去解决问题。因此我们可以对各种编程语言进行不同维度的分类,以便更好的选择。
[ ]
中括号——它既可以是数组,又可以是列表,还可以是哈希表。脚本征服“跨操作系统”难题,采用的另外一种方法:让自己的源码变得方便移植。其实这个方法,C 语言很早就尝试过,所谓的 ANSI C,就是明白无法让 C 语言编译出来的程序在任何环境运行,那就让 C 语言的源码变得可以在任何环境编译吧,虽然这个尝试现在来看不是太成功,因为我们使用 C 语言的一个重要理由,就是用来对操作系统进行控制,不同的操作系统提供的 API 本身就差异很大。脚本类语言只需要在不同的操作系统上,实现一遍自己的解析器,就可以成为所谓的跨操作系统了。其中一些语言(譬如 Python),还会连带把自己的常用库也移植到不同的操作系统上,而另外一些语言,压根就没有什么库,它的设计目的就是“寄生”(嵌入)到其他语言编写的程序中(如 Lua),所有需要移植的“库”,都是被嵌入的那种语言自己需要解决的问题。如果要选择一种语言来作为某个项目的开发语言,我一般会这样思考:
由于学习一门新的语言,可能会消耗很多精力和时间,所以一般我们感情上并不喜欢学习新的编程语言。但是,当年学会了第二门语言的时候,你才真正的懂一门语言,这句话在编程方面也是对的。而且学的语言越多,学习的速度越快,而且越能欣赏到这些语言设计者在解决问题时的思考。
上面讨论的大部分语言,都可以称为“通用型”语言;然而,编程中,我们往往还会碰到另一类编程语言,它们可以被称为“专用型”语言。最广为人知的可能是 SQL(Standard Query Language),我们用这类语言来操作数据库。还有一类被称为 Shell Script 的语言也很常见,譬如在 Windows 上的 .bat 和 .ps1 文件中,编写“批处理”命令,在 Linux 上则是 bash 以及其他各种 sh。我们常见的 HTML,实际上也是一种专用型语言,叫做“超文本标记语言”(Hyper Text Marker Language)。
select name, age, address from User where name = 'Tom';
从易用性上来说,一般“专用型”语言 DSL(Domain-specific language)相对会比较简单一些。因为这类语言比较少需要把“循环、分支”表达能力一起包含进去,甚至有一些用 JSON/YAML 的配置文件,都可以称之为一种 DSL。从这个角度来看,编程对人的要求其实并不高。
和通用型语言不同的,DSL 语言基本上都是只运行于某个特定的软件之内,所以使用 DSL 其实需要学习的最大负担,实际上这个宿主软件的功能。有的软件功能极其复杂,只好用通用语言来充当原来的 DSL,譬如在微软 Office 软件 Word、Excel 上面的“宏语言”,实际上是 VisualBaisc for Application,简称 VBA 语言,甚至曾经出现过用这个语言编写的“宏病毒”。
虽然有人很热衷于讨论各种编程语言的性能表现,但是绝大多数编程语言都是为了写程序更方便而创造出来的。从汇编语言开始,到 C 语言,再到后面的 Go 语言等等。当我们在学习编程语言的时候,关注点应该更多是,一门语言到底用什么方法,去帮程序员提高开发效率。
譬如以 C 语言为例,if 和 while 关键字,就解决了大量的汇编上跳来跳去的问题,而 function 则对一个“子过程”提供了内存管理和代码跳转的很好抽象。又如 Java 语言,提供了标准的 JDK 让程序员有一个可用的基本类库,节省了大量自己造基础轮子的时间;内置多线程的支持,synchronise 关键字又简化了并发程序的编程方法。Go 语言可以返回多个返回值,一方面为错误处理提供了方便,另外一方面也避免了定义大量的结构体(类)。
各种语言的面向对象语法的支持,一般来说,都提供了多态支持,简化了程序扩展中的经典语法:switch case。而且多态也大大简化了程序员去学习和记忆大量相似函数库的工作。譬如多家数据库厂商,针对同一套接口推出各自的实现,程序员可以学习一次数据库的使用,就能使用多种不同的数据库。
对于已经掌握了一种语言的开发者来说,另外一种语言的用法,可能会让人感觉比较别扭,但是这背后的原因,可能是因为那种语言,在尝试解决一个其他语言没有去解决的问题。譬如 python 语言的代码块不是用大括号封起来,而是用的缩进,这样做是为了“强迫程序员写好缩进”,还有另外一个好处,就是不需要准确的为每个括号进行配对(虽然这个问题在现代 IDE 的帮助下已经不是问题了)。
几乎所有的语言,都是希望跨平台的。这里的平台包含硬件平台、操作系统、宿主程序等等。但所有的跨平台能力,都需要付出一定代价:编译型语言的跨平台,就需要跨平台的编译器;虚拟机语言的跨平台,需要跨平台的虚拟机;脚本语言则需要跨平台的解析器。另外,跨平台还需要对平台相关的功能,进行一定程度的统一抽象和封装。譬如 windows 和 linux 的文件系统有很多差异,如果要跨平台进行文件读写,必须要抽象成统一的文件操作 API。
编程语言的安全性,除了包括可能出现的软件漏洞,还包括了减少程序员 BUG 产生的设计。譬如 C 语言由于对内存管理的支持很少,所以容易出现栈溢出漏洞、内存泄露、以及指针错误导致的崩溃;C++ 为此增加了一整套的 STL,在基本容器上减少了很多内存管理的 bug,但指针的使用依然很容易导致内存泄露和程序崩溃;Go 语言保留了指针,但不允许指针运算,而且自动管理全部变量内存,因此指针导致的 bug 被大大减少了。
Java 的异常捕捉“围栏”机制,强迫程序员处理每一个可能的异常,确实是一种提高安全性的好办法,但是这也让程序编写效率变低。Go 语言则使用错误返回的“惯例”来处理异常,开发效率是上去了,但是不免发生忘记判断返回值的问题。虽然 C++ 也有异常,但是因为没有内存管理,异常本身的内存分配反而容易变成一个问题,所以用的人需要更加小心翼翼。
游戏行业内,C++ 是最常见的一种语言。那么,到底为什么是 C++,而不是其他语言呢?有人会说,是因为游戏对性能要求比较高,同时业务逻辑也比较复杂,能承担这两点的语言,C++ 基本是唯一选择。这个理由,我觉得有一定的道理,但事情往往并不是简单的理论分析就可以看明白的。我觉得最主要的原因,是开发工具:这里最常见的开发工具,就是微软的 DirectX,这套库是 C++ 的,所以很多游戏就使用了 C++ 来开发。由于游戏团队中必须要用 C++,所以没必要增加其他编程语言,能用 C++ 的就也都用了吧。因此很多配套的游戏服务器端程序,也就用了 C++,毕竟团队比较熟悉。这也导致了为什么其他行业的服务器端,基本不用 C++,譬如电商、社区,而游戏服务器都是 C++ 的原因。
C++ 的开发效率实在算不上高。也有一些团队,从游戏服务器端开始,不用 C++,而是用 Java 或者 C#。由于 Unity 引擎默认支持的语言是 C#,所以服务器端也用 C# 也是一个常见的选择。说到底还是开发工具决定了语言。比较有意思的是,虽然 Unreal 的底层是 C++ 的,但是依然有很多团队会用 Lua 脚本来写逻辑。
使用脚本语言来写游戏逻辑,其实也是游戏的一个传统。Python、Lua、Js 在游戏行业内使用的都比较广泛。其中以 Lua 最为常见,因为这种语言的解析器非常小,性能也不错,很适合嵌入在 C/C++ 编写的其他程序中。作为脚本语言,还有支持“热更新”的优点,游戏的玩法变动非常频繁,这个特性对于游戏来说非常的重要。
回想起来,为什么争论语言特性是程序员的一大“爱好”?原因除了大家都能参与、各自投入了心血以外,还有一个原因,就是写代码这件事其实比写文章要困难一些。如果我们能多写写几种不同语言的代码,很多的“争论”反而会成为我们深入了解这门语言的一个契机。在实践中比较,不管别人是否认可,自己的体会才是最重要的。