不是“函数”的“函数” - 内建函数
学过Go语言的同学一定知道make这个“内建函数”:
若按官方定义,它的确是一个“built-in function”,然而,这个function可不是一般意义的function,具体地说,你要是想自己实现一个和它一样接口的,是做不到的,原因很简单,因为第一个参数是type而不是表达式,且不是反射那种形式
所以make从语言实现角度说,也可以看做是一个特殊语法,编译器识别出这个“函数”后,并不是找到它对应的某个函数代码实现去调用(实际上大概率是没有的),而是当做for、if之类的关键字来处理,走生成代码的流程
类似的,append、delete之类的内建函数其实也可看做是做成函数样式的语法,例如append:
虽然参数和返回值都是值,但是在没有泛型支持的情况下,也没法直接实现(当然1.18后是可以了),显然,编译器在碰到append的时候是得做一些特殊处理的
作为对比,某些其他内建函数,例如println,从形式上就是一个普通函数,且自己实现也没有什么难度了:
不过由于一些特殊原因,print和println依然被设计为内建函数,并不是一个普通函数
一般而言,我们在讨论一门高级语言的语法组织的时候,可以简单将其分为“语法+库”的形式,其中前者可以认为是由保留字(即不能用来做变量名的那些关键字,如if、class之类)来实现,由编译器直接处理,后者则是通过函数或类或语言支持的其他形式实现的库来支持,后者一般被称为标准库,虽然标准库不一定是语言自实现的(例如有的功能必须用更low level的语言,如汇编来实现),但从形式上,可以认为标准库和我们自己写的三方库在使用上是相等的,或者说,我们完全可以通过自实现某些标准库功能,然后替换对应的库来实现同等功能,甚至override覆写相关功能
比如说你在Python里面就可以这样搞:
注意,这不是在当前namespace写一个len来隐藏掉标准的内建len,而是直接干掉了标准库的len,由于Python极度自由的动态性理念,这么干是可以的(当然在一个正式项目中这样搞就是危险的)
但是,你没有办法去通过合法手段覆盖保留字实现的功能,例如变更if语句的执行过程之类,这些是语法,而不是库,从这个例子也大概可以看到上述的两层结构的区别
上面这套二层理论,看着挺完美,层次简洁,各层功能明确,容易理解且扩展性强大,但如开头说的,Go语言在二者之间搞了内建函数这一层,把情况变复杂了,其实不光Go语言,其他一些高级语言,或编译器扩展,也会搞出一些类似的东东,这其实可看做是一种折中方案
例如,为什么不将一些功能(例如上面说的Go的make)做成语法呢,这也许是不想让语法本身变得过于臃肿,并保留一些灵活性,例如,Go的make依然是一个可用的标识符:
另外,语言本身是不断发展的,发展过程中会引入各种新特性,而这些特性的实现如果随便采用增加保留字的方式,就很容易造成不兼容的情况,例如想象一下,如果哪天C++规定“count”成为了保留字,那么会有多少项目编译不通过?
所以在发展问题上,各语言可为煞费苦心,C和C++会告诉你,单下划线或双下划线开头的标识符不要随便用,这些有可能被语言自己用到(例如新类型“_Complex”),如果新增语法是“热门常用”语法,关键字用下划线开头过于难看,则也会尽量使用非保留字的关键字,也就是特殊标识符来实现,例如之前视频提到过的final和override:
于是在这种情况下,如果要新增语言特性,通过由编译器介入处理的特殊内建函数来实现是一个不错的选择,它们在语法上是函数,但是在语义上则可能和“函数调用”差了十万八千里,因为根本没有对应的函数实现,只是一种语法伪装罢了
例如,用C++写日志库的时候,我们一般需要打印出记录日志的位置信息,传统的做法是通过宏来实现:
GNUC扩展提供了内建函数来实现相关功能:
显然,这俩函数大概率是没有对应的代码实现,完全就是编译器对其做替换罢了,只不过,__FILE__和__LINE__是预处理器做替换,而这两个内建函数是编译阶段做替换,这有什么意义呢?由于语法上是一个“函数”,在编译期处理,所以语法功能上比宏要强一些,例如:
了解C++参数默认值原理的很容易看懂这里为什么能work,用宏是较难做到类似效果了
既然新增语言特性是用函数形式,那么为什么不直接用库函数实现呢?原因也是很显然的,因为正常函数代码没法实现,必须编译器介入
例如,C程序员知道有个内建的“函数”offsetof:
它可以获取一个结构体中某个成员的相对偏移,这个声明显然不可能是一个函数,因为传入参数是一个type和一个member name,所以文档中说清楚了,这其实是一个宏:“The macro offsetof()”
很多资料会告诉你,这个宏是这样实现的(形式不唯一,但原理差不多):
看上去很巧妙对不对?然而我们知道,C和C++语法规定:“p->q”本质上是“(*p).q”,隐含了一个解引用操作,然后这里对空指针(假设空指针值是0)或无效指针(假设空指针不是0的话,虽然一般也不太可能)解引用,UB了(尽管并没有去访问m,但依然是UB)
的确有一些编译器的libc中是这样实现offsetof的,这些编译器对于相关UB也没有处理,是能work,但是对于一个有着很强的优化算法,并且追求语法严谨的编译器来说,会怎么实现呢?看看gcc的操作:
人家直接提供了内建函数来实现这个功能(clang也是这样弄的)
当然,对于上面通过地址0取m偏移的做法,相关编译器特殊识别一下倒也不难
那么编译器在优化的时候能不能自动识别是不是offsetof?答案是不能直接识别,因为宏替换是预处理阶段的事情,编译器要感知这个就只能用一些间接手段了
类似的另一个例子,是C语言stdarg.h头文件中的va_list处理的相关宏,处理可变长度参数,不少资料会一本正经跟你说,由于函数参数是挨个压栈,所以这几个宏实际就是根据指定类型调整指针偏移来遍历每个可变参数
但是稍稍了解现代编译器优化的就知道这不靠谱,为了效率,函数传参很多时候是通过寄存器组来进行的,地址都没有,谈何指针遍历?
所以GNUC的va arg相关过程怎么实现也猜得出来了:
还是得编译器介入才行,其实这种做法同时也屏蔽了下层实现,移植性更好
最后,我们把目光放在那种标准库中可以用普通方法实现的函数身上
前面Python的例子中我们看到,Python标准库的内建函数是可以被覆盖替换的,那么其他语言中也是类似的情况吗
这样一个例子(C++):
写两个cpp文件,假装实现memset,并将其连接运行:
按我的想法gcc的链接是从左到右的,libc应该是最后一个被依赖的库,那么2.cpp中的memset应该优先被连接进来,从而覆盖掉标准库的,但结果并不是这样
而如果将第一行的string.h的包含去掉,就能打印hahaha了,这至少说明,编译器在这里是介入了一些操作的,如果查看1.cpp对应的汇编就可以看到,在包含string.h的情况下,编译器会认为这个memset就是标准库的memset,直接将这行改为等价的a=0
这也就是说,标准库虽然从形式和理论上是和编译器分离的库,但在C这里,它也是语言的一部分,并且是被编译器的处理流程考虑在内的,并不是傻乎乎直接去调用,即便你偷偷替换掉系统的libc,在上面的例子中也不会产生任何影响
事实上,C和C++规定,对于标准库(包括STL)的覆盖、模板特化、改动都是未定义行为,换句话说,编译器有权假设它们的行为就是标准规定的那样,从而可以得到更大的优化空间,这也是为什么禁止在C++中自行“namespace std”给标准库添加东东的原因。尽管它们被各种资料宣传为底层语言,但程序员其实并不自由