说到编程语言,C 语言是一个绕不过的话题。一直到今天,这门历史悠久的语言,依然是软件开发中最常见的语言之一。很多人都说,学编程必须要要学 C 语言,但是事实上,不会写 C 语言的程序员也比比皆是。只是简单的学习过一下,没有真正的开发过工程,是不能叫做“懂 C 语言”的。那么,到底 C 语言是不是一定要学的呢?我觉得要学,也可以不学。下面说说我的理由。
操作系统的原生语言
大概大家都知道,我们现在用的很多操作系统,譬如 LINUX,都是用 C 语言开发的。那么,是因为 C 语言“特别好”,所以操作系统才用 C 语言开发的吗?我觉得相当大的原因是历史造成的,也就是说,当很多操作系统在第一个版本的开发时,C 语言可能是当时的最好的开发语言。
现在我们说到操作系统,譬如 iOS、安卓、windows,似乎操作系统是一个提供给用户,进行应用程序的安装、卸载、运行的平台软件。有些还会自带一些好用或者不好用的软件。但事实上,操作系统远远不止上面说的这些,甚至可以说,提供给最终用户进行操作的界面,并不是操作系统的核心功能。操作系统的真正的核心功能,是提供对硬件(最主要的是内存、CPU、磁盘)的功能封装和细节屏蔽,简单来说,操作系统的主要用户是应用程序的开发程序员。微软的第一桶金 MS-DOS 系统,全名是 Microsoft Disk Operating System,翻译过来就是“磁盘操作系统”,看起来是不是就特别“硬件”?
由于操作系统,对于大多数的计算机外设,譬如磁盘、网卡、显示卡等,都做了功能封装,这样应用程序开发者就不需要针对硬件去编程,而是只需要使用操作系统提供的编程接口,就可以使用这些外设的能力了。正因为 C 语言是很多操作系统的开发语言,所以很多操作系统都提供了 C 语言的 API。因此很多开发者都选择继续使用 C 语言来开发其他程序了。
我在使用 JAVA/C#/PHP 等语言的时候,会比较注意能找到什么样的“库”或者“SDK”,因为我的程序可能需要依赖这些“库”。举个例子,我要读写操作系统的“共享内存”,如果我用 C 语言开发程序,我可以直接调用操作系统提供的 C 语言 API,在 LINXU 上就是所谓的“系统调用”;如果我用 Java,就必须要找到 MappedByteBuffer 这个类,并且只能用 mmap 类型的共享内存,至于其他类型的共享内存功能,可能就要再找找有没有人封装过了。如果没有,那你就需要自己写一个符合 JNI 标准的 C 语言程序,封装一下这个功能函数,然后再提供给 Java 调用。——看,这不还是得写一些 C 的代码吗?所以,直接用 C 语言来写应用程序,就可以避免这个麻烦。
3L 和 ABI 规范
刚刚上面提到,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 程序往往是这样的:
由于链接的过程,是由各个编译器软件来实现的,并不是统一在 C 语言的规范里,也没有一个公司或者组织来约束,所以使用不同的编译器,以及使用不同的编译器生成的库的时候,就会出现大量的“兼容”问题。加上 C 语言也没有后来语言的“包依赖管理”的系统,所以计算链接同一个库,如果用的是不同的版本,也可能出现链接错误,这些问题,也是 C 程序员需要经常处理的问题之一。
尽管 ABI 和链接规范有很多问题,但这些确实是我们现在操作系统的真实底层原理。所以当我们没有其他方法的时候(或者不想使用其他方法),我们最后还是有 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 语言中的变量,虽然有各种类型,但实际上编译器几乎不会自动的对其进行什么操作,于是不管是什么类型,其实都代表的一块内存而已。类型不同仅仅是代表不同长度的内存块。而在不同编译器和不同操作系统下,同样的类型对应的长度还不一样,这就更增加了这门语言的复杂性。譬如 32 位系统下的 long int 是 4 个字节,64 位系统下则是 8 个字节。本来这种长度差异不太应该影响程序员编码,但很多 C 的库又设计成使用 指针+长度 的方式来传参,所以变量长度变得不得不关心了。同样的问题还有结构体的字节对齐问题。
那么最后来说,C 语言是不是作为程序员,必须要学的语言呢?从开发实践上来说,不是必然要学。很多编程岗位,并不会因为你懂 C 语言就给你躲开工资。但如果你懂这门语言,用这个语言开发过程序,你会有一种接触底层原理的感觉。计算机科学的基本形式,就是层层抽象。而 C 语言,刚好处于擅长形式化的高级语言,和汇编这种硬件操作语言之间。穿透了这层抽象,就能触摸到硬件的层面,从而对计算机科学有更深一层的理解。
除了资源管理,我们写的程序现在往往都是“并发”的,譬如多进程或者多线程的。如果没有任何工具,我们是很难控制多段“同时”运行的代码,对同一块内存(变量)的读写结果。可能你想运行 i++,但是这个变量在多个线程同时运行时,可能 i 会被赋值为其他值;如果你把这个变量作为循环判断值,有可能你的线程会陷入死循环……
不过,对于并发问题的处理,除了多线程以外,单线程异步是一种运行效率更高的方式。因为有可能节省大量的线程栈内存的占用,而且也可以利用到 Linux 的 epoll 能力。java.nio 提供了比较好的支持,不过,对比多线程的支持,异步回到或者“协程”的支持就没有 Go 语言那么好。
Java 的多线程,在 Linux 上还是使用 pthread 库,用子进程来模拟的线程。虽然 Linux 的多进程性能也相当不错,但是在成千上万的“java 线程”的疯狂切换的情况下,对内存和CPU都会造成比较大的压力。这个问题也是后续其他很多语言和框架着眼的地方。譬如 go 语言就会根据 CPU 的核心数来启动真正干活的子进程,而编程概念上的“协程”和真正的子进程是不捆绑的。
C#:我全都要
C# 就是 Java 异父异母的亲兄弟:两者都号称可以跨平台,也确实做到了windows/linux 双栖;两者都是运行字节码代码,有自己的虚拟机进程;以前觉得 M$ 特别封闭,觉得 SUN 相对开放,现在反过来对比,微软比甲骨文更开放。
如果不使用继承,即便相似的功能,也必须要定义很多用法类似,但名字不同的函数(库)来提供给程序员。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++ 代码。同时,一般的 C++ 编译器,也能很好的链接 C 写的库。但是,如果不特别的标注 extern "C",C++ 写的库是不能被 C 语言代码链接的。C++ 为了在语法上兼容 C 语言,让很多新的特性“嫁接”在 C 语言的概念上。譬如 指针 这个概念,整个面向对象的动态绑定,几乎都利用‘指针’来表达(另外还有 C++ 专属概念‘引用’)。同样的还有 struct 这个关键字,C 语言和 C++ 语言都有这个关键字,但真正的功能可像相差很远。对于 C 语言来说,结构体变量的内存长度、布局其实是比较简单的,但是 C++ 的对象可不简单,而且很多公司面试很喜欢问这个。
C++ 在面向对象的多态上,几乎完全依靠“指针”。由于 C 语言当中,变量的类型决定了变量的内存数据,所以你一旦声明了一个父类变量,这个变量就时固定为父类对象了,再也没有机会用作任何的子类对象变量, C++ 也兼容了这一点,但是如果没有办法拿一个父类变量作为子类变量使用,动态绑定就无从谈起,于是 C++ 就借用了“指针”这个概念:所有类型的指针,内存长度都是一样的。于是 C++ 的整套面向对象的动态绑定(多态)机制,就都建立在指针上了。
Parent *obj = new Child()
如果对于指针搞不明白,不但 C 语言玩不转,C++ 也是基本没法用的。这个糟糕的星号,从 C 语言一直留到 C++。
静态绑定:真正的架构师语言
如果你希望写一套程序库,而且希望约束使用者的用法,那么你除了希望这个库有足够的功能外,肯定也希望编程语言能提供给你一些工具,能够让用户能足够灵活的使用你的库。特别是对于“有一部分”代码,你预期是使用者编写,然后放在你的框架内运行的情况,俗称“回调”,譬如说你写了一个 web 服务器的框架,希望使用者只用填写访问某个 URL 就执行的函数;或者说你写了一个游戏的框架,希望使用者只编写某个角色被击中的效果等等。
这种代码在传统的面向对象变成方法上,一般需要定义一个 interface,然后让使用者来实现。这种扩展方法,也是导致“类爆炸”的原因之一,因为使用者如果使用了多个框架,那么为了使用这些框架而写的回调函数,可能需要定义一大堆 interface。而 C++ 的另外一个特性,就很好的解决了这个问题,这就是“模板”功能。
有的人会认为“模板”特性,几乎是另外一种语言。然而“模板”特性被用在 C++ 最重要的组成部分 STL 里面,已经成为 C++ 这个三位一体语言(C语言、面向对象、模板泛型)不可缺少一部分。所以 C++ 如此的复杂,是因为其实整合了三类特性到一门语言中。“模板”特性虽然复杂,但是用来开发被复用的模块,却有非常大的好处:
在“虚拟机”下运行的语言,往往都具有非常丰富的运行时动态特性,譬如 JAVA 和 C#,反射功能只是一个最基本的操作,它们还可以运行时更新代码,甚至可以支持很多不同的语言在同一个虚拟机上跑(如 Jython 就是用 Python 语言跑在 JAVA 虚拟机上)。比较有趣的是,几乎所有这类的语言,都号称要“跨操作系统”。事实上它们也基本上都做到了这点,但是真正用于编写 PC 或者服务器的跨操作系统的项目非常少,反而在手机、游戏领域,JAVA(安卓) 和 C#(Unity) 这些语言却应用非常广泛。最后说说性能,在 JIT(Just In Time)技术的加持下,很多虚拟机字节码,实际上拥有了和编译语言一样的基础性能,而那些无法 JIT 的代码,往往是编译型语言不支持的一些动态特性。所以除了部署安装这类语言编写的软件,需要额外按照个环境(JRE/.NET)以外,使用起来没有什么实质上的差异。
脚本语言的历史其实一点也不比其他语言更短,尽管它们被认为是最容易学习,但性能最差的一批。这类语言的一般都具有所谓的“动态类型”特性,也就是你可以不理睬变量的类型,直接把变量思维万能的信息盒子。甚至一些常用的数据结构,也被一种很容易使用的方式嵌入在语言中,譬如世界上最好的语言 PHP 就可以使用其万能的[ ]中括号——它既可以是数组,又可以是列表,还可以是哈希表。脚本征服“跨操作系统”难题,采用的另外一种方法:让自己的源码变得方便移植。其实这个方法,C 语言很早就尝试过,所谓的 ANSI C,就是明白无法让 C 语言编译出来的程序在任何环境运行,那就让 C 语言的源码变得可以在任何环境编译吧,虽然这个尝试现在来看不是太成功,因为我们使用 C 语言的一个重要理由,就是用来对操作系统进行控制,不同的操作系统提供的 API 本身就差异很大。脚本类语言只需要在不同的操作系统上,实现一遍自己的解析器,就可以成为所谓的跨操作系统了。其中一些语言(譬如 Python),还会连带把自己的常用库也移植到不同的操作系统上,而另外一些语言,压根就没有什么库,它的设计目的就是“寄生”(嵌入)到其他语言编写的程序中(如 Lua),所有需要移植的“库”,都是被嵌入的那种语言自己需要解决的问题。
虽然有人很热衷于讨论各种编程语言的性能表现,但是绝大多数编程语言都是为了写程序更方便而创造出来的。从汇编语言开始,到 C 语言,再到后面的 Go 语言等等。当我们在学习编程语言的时候,关注点应该更多是,一门语言到底用什么方法,去帮程序员提高开发效率。
譬如以 C 语言为例,if 和 while 关键字,就解决了大量的汇编上跳来跳去的问题,而 function 则对一个“子过程”提供了内存管理和代码跳转的很好抽象。又如 Java 语言,提供了标准的 JDK 让程序员有一个可用的基本类库,节省了大量自己造基础轮子的时间;内置多线程的支持,synchronise 关键字又简化了并发程序的编程方法。Go 语言可以返回多个返回值,一方面为错误处理提供了方便,另外一方面也避免了定义大量的结构体(类)。
对于已经掌握了一种语言的开发者来说,另外一种语言的用法,可能会让人感觉比较别扭,但是这背后的原因,可能是因为那种语言,在尝试解决一个其他语言没有去解决的问题。譬如 python 语言的代码块不是用大括号封起来,而是用的缩进,这样做是为了“强迫程序员写好缩进”,还有另外一个好处,就是不需要准确的为每个括号进行配对(虽然这个问题在现代 IDE 的帮助下已经不是问题了)。
跨平台
几乎所有的语言,都是希望跨平台的。这里的平台包含硬件平台、操作系统、宿主程序等等。但所有的跨平台能力,都需要付出一定代价:编译型语言的跨平台,就需要跨平台的编译器;虚拟机语言的跨平台,需要跨平台的虚拟机;脚本语言则需要跨平台的解析器。另外,跨平台还需要对平台相关的功能,进行一定程度的统一抽象和封装。譬如 windows 和 linux 的文件系统有很多差异,如果要跨平台进行文件读写,必须要抽象成统一的文件操作 API。
Java 的异常捕捉“围栏”机制,强迫程序员处理每一个可能的异常,确实是一种提高安全性的好办法,但是这也让程序编写效率变低。Go 语言则使用错误返回的“惯例”来处理异常,开发效率是上去了,但是不免发生忘记判断返回值的问题。虽然 C++ 也有异常,但是因为没有内存管理,异常本身的内存分配反而容易变成一个问题,所以用的人需要更加小心翼翼。
和游戏有什么关系呢?
为什么是 C++ ?
游戏行业内,C++ 是最常见的一种语言。那么,到底为什么是 C++,而不是其他语言呢?有人会说,是因为游戏对性能要求比较高,同时业务逻辑也比较复杂,能承担这两点的语言,C++ 基本是唯一选择。这个理由,我觉得有一定的道理,但事情往往并不是简单的理论分析就可以看明白的。我觉得最主要的原因,是开发工具:这里最常见的开发工具,就是微软的 DirectX,这套库是 C++ 的,所以很多游戏就使用了 C++ 来开发。由于游戏团队中必须要用 C++,所以没必要增加其他编程语言,能用 C++ 的就也都用了吧。因此很多配套的游戏服务器端程序,也就用了 C++,毕竟团队比较熟悉。这也导致了为什么其他行业的服务器端,基本不用 C++,譬如电商、社区,而游戏服务器都是 C++ 的原因。
为什么不是 C++ ?
C++ 的开发效率实在算不上高。也有一些团队,从游戏服务器端开始,不用 C++,而是用 Java 或者 C#。由于 Unity 引擎默认支持的语言是 C#,所以服务器端也用 C# 也是一个常见的选择。说到底还是开发工具决定了语言。比较有意思的是,虽然 Unreal 的底层是 C++ 的,但是依然有很多团队会用 Lua 脚本来写逻辑。
评论区
共 20 条评论热门最新