高级语言中的成语——函数

《高级语言中的成语——函数(子程序)》源站链接,阅读体验更佳!
函数是所有的高级程序设计语言都会提供的特性,函数就像是自然语言中的典故和成语,在我们写文章的时候如果引用典故或者是用成语就可以用非常少的文字表达丰富的含义;而在高级程序设计语言中,通过函数,我们可以把一些常用的功能封装起来,在需要的地方去调用它。

在使用汇编进行程序开发的时候,就有子程序的编写;在每一次用到这个功能的时候就把CS:IP定位到这个子程序的入口,以使用这个功能。

但是在汇编中进行子程序设计的时候我们要考虑的事情实在是太多了——我们在使用子程序的时候要为子程序准备数据,而在子程序的运行过程中还会产生数据,对于这些数据,我们要自己协调硬件资源;同时,我们在使用子程序的时候由于不知道子程序中会使用到哪些硬件资源——比如寄存器,为了防止硬件资源发生冲突导致程序的状态发生错误,我们在将CS:IP重定向到子程序的时候还要进行必要的现场保护…

高级语言中对子程序进行了抽象,把子程序抽象成了函数。在高级语言中使用函数就比在汇编中使用子程序幸福的多了——高级语言中的函数,我们不用再考虑硬件的编排,使用起来非常直观清爽。在理想的情况下,我们只需要给函数传入所需的参数,然后关注函数的返回值就可以了。

但是,有一些函数除了参数和返回值之外,还有可能和外部发生隐式的数据交换,这个时候我们称函数具有副作用,使用具有副作用的函数除了关注函数的参数和返回值之外,还需要关注程序的上下文对函数的影响以及函数对程序上下文的影响,使用具有副作用的函数需要我们更加谨慎。

函数为高级语言提供了更高层次的抽象能力,使用函数,我们可以把操作某种数据类型的代码封装起来,在更高的抽象层次上或者说更加灵活的使用数据类型;我们可以把代码的功能划分成一个个函数…函数提高了代码的模块化能力,提高了代码的重用率。

但是,不同的语言中对函数的抽象层次可能是不同的,有些语言的函数就支持具名参数和参数默认值比如Python和Kotlin;有些语言中的函数支持入参和出参比如C#;而有一些语言中函数可以一次返回多个值给调用方,比如GoLang。而函数的声明和调用在我们的日常编码中是一个非常基础且高频的操作,不同语言之间对函数抽象层次的不同可能会造成我们从一门语言迁移到另一门语言之间产生壁垒,因此这篇文章我就对各种函数调用的方式进行一个简单的总结。

同时,有些语言中支持函数指针或者是把函数作为第一公民,或者是通过其他的方式(比如Java中的SAM(单抽象方法、函数式)接口)间接实现了把函数作为第一公民,这种情况下的函数变量可以实现日趋火热的函数式编程,函数指针在某些场景下可以代替接口,实现更加灵活的编程或者实现某些轻量级的设计模式。

这篇文章我将对函数进行各种角度的分类和剖析,讨论一下我对高级程序设计语言中函数的基本理解。

面向对象的函数叫方法

在某些面向对象的语言中会有方法的概念,那函数和方法这两个概念有什么区别呢?一般来说,方法是特殊的函数,是面向对象性质的函数。所以,在非面向对象的语言中比如C语言中,只有函数的概念而没有方法的概念。那么,在面向对象的语言中呢?如下Java代码:

public class Person {
    public void drive() {
        // 驾驶代码实现
    }
}

这里的dirve()叫类的成员函数,有的语言觉得成员函数这个名称太长了,就简称为“方法”,Java就是这样的。

它和普通的函数有什么区别呢?如果我们想要调用上面的drive方法,就必须先有一个Person对象,然后通过这个对象来调用drive:

Person person = new Person();
person.drive();

其实,这只是面向对象语言的一种语法糖,真正编译出来之后drive函数可能是void drive(Person person)这种形式的,而编译器也会把person.drive()这行代码自动翻译成drive(person)这样的形式。

也就是说,方法是针对特定类型编写的函数,它其实是对特定类型功能的扩展,就像上面的drive()函数,它就是针对Person这种类型的,我们在调用它的时候必须为其提供一个Person对象,方法本质上也是一种函数。这个时候我们可以称person对象是drive函数的主调参数

Java中的接收器参数

在Java中,从Java8开始我们可以在类的实例方法或者是内部类的构造方法中声明接收器参数,接收器参数的名称必须是this,同时必须位于参数列表的首位,如下代码所示:

package site.laomst.learn;

public class ClassTest {
 public String sayName(ClassTest this) {
     return "laomst";
 }

 public static void main(String[] args) {
     ClassTest test = new ClassTest();
     String name = test.sayName();
     System.out.println(name);
 }
}

上面代码的第四行中的sayName方法的第一个参数就是接收器参数,其实上面的写法和public String sayName()是等价的,这样的写法只是为了更方便地使用注解而已,其实这里的接收器参数就体现了方法本质上是一个面向对象的函数,它的第一个参数就是主调对象或者成为接收器参数

类似的还有Python和PHP中的self参数。

如果有使用过基于JVM的Kotlin语言的同学,可以去研究一下Kotlin中的扩展函数,其实就是使用了这个原理来对现存的类进行扩展的。后面我们在介绍面向对象编程相关的内容时,介绍类扩展的时候也会用到类似的概念。

所以,在接下来的讨论中,我们将不再区分函数和方法这两个概念,而统一使用函数这个概念

参数是函数的原材料

参数是函数的输入,我们在声明一个函数的时候要声明这个函数所要接收的参数,而我们在调用这个函数的时候要向这个函数中传递其所需要的参数,由此就派生出了两个概念——形式参数和实际参数。

  • 形式参数

    我们在声明一个函数的时候就要声明这个函数所要接收的参数的列表,这个列表其实是一个个左值,它们引用栈中的某个数据存储区域,我们称这个列表中的左值为函数的形式参数。

    形式参数是函数体中的一个局部变量,我们每一次调用函数都会产生一个新的栈帧(局部作用域),而每一次函数的形参都会在这个局部作用域中注册一个新的绑定关系以引用我们在调用函数时所传递过来的实际参数。

  • 实际参数

    我们在调用一个函数的时候向函数传递的值,在我们调用函数的时候,在函数的形参上会发生一次LHS,即函数的形参会被我们传递的实参赋值。

    上面提到过每一次调用函数都会产生一个新的局部作用域,函数的形参会在这个作用域中注册绑定关系来引用实际参数,那么我们调用函数时传递的实参和声明函数是所声明的形参是怎么对应的呢?这在下文中介绍。

parameter & argument

我们在查阅英文文档的时候经常会遇到parameter和argument这两个术语,这两个名词译为中文的时候都有’参数’的意思,但是在英文文档中它们往往出现在不同的上下文中,所以很明显二者具有不同的含义。

其实我第一次注意到这两个名词的不同含义是在阅读中文版的《Java语言规范 基于SE8》一书的时候,碰到了一个从未遇到过的概念——引元。当时我在网上各种Google和百度,都没有找到这个定义的明确解释。最后我查阅了此书的英文原版,发现在出现引元的上下文中所对应的英文单词总是’arguments’,而在出现参数的上下文中对应的英文单词总是’parameter’。然后我在维基百科的英文网站上查阅了这两个名词,最终得到了以下的结论:

  • parameter翻译为参数、形参
  • argument翻译为实参、引元、引数

也就是说parameter代表形参,而argument代表的是实参,相应的,引元、引数也就是实参的同义词了。这两个单词在我们阅读英文文档的时候需要多加注意。

参数的值传递、引用传递和址(指针)传递

很多人在刚开始学习编程的时候接触的编程语言可能就是C语言,C语言是支持指针的,而我们在声明一个函数的时候其形参的类型也有可能是一个指针,因此在C语言中我们就经常会遇到这样一个问题——一个函数的传参方式是值传递还是址传递,而且在往往还会跟着一个swap函数的例子来进行说明。

如果有接触过C++的同学可能还接触过引用的概念,这就牵扯出了编程中的一个基本知识点:值传递、引用传递和指针传递之间有什么区别和联系呢?如果我们只是单纯学习一门语言的话,这并不难理解,但是当我们接触并学习多门语言之后,反而会产生困惑,似乎各种语言都在争夺概念的定义权,反而让这些概念变得扑朔迷离,这个时候我们更要透过现象看本质,摆脱无谓的名词之争。

先说一下我们之前的疑惑:C++中完整实现了上述的三种参数传递方式,但是Java和C#等只实现了两种——值传递和引用传递。但是,如果从C++的角度来看的话,Java和C#中的引用传递其实就是使用了指针传递的方式,但是它们也称这是引用传递,这是为什么?接下来,我们捋一捋其中的细节:

  • 值传递

    值传递最简单直观。传递过去的永远是原始数据的复制版本,满足这个就是值传递。值传递的情况下,函数中对数据的影响就是单向的,我们永远不用担心输入的数据会被修改。但是缺点是效率低,因为动不动就需要复制数据。

  • 指针传递

    严格来说,指针传递和引用传递以及值传递不是同等级的概念。如今,值传递和引用传递上升到了一种“实现标准”,而指针传递是一种具体的技术。例如经典的swap函数void swap(int *a, int *b);就是一个非常典型的指针传递的函数。

    有意思的是,swap函数的函数声明中a、b这两个参数的类型是指针,这就要求我们调用swap函数的时候就要给他提供指针值:

    int a = 1;
    int b = 2;
    swap(&a, &b);
    

    如上面第三行代码,我们调用swap的时候必须利用取地址操作符计算出变量的指针值传递给swap函数,也就是说指针传递中指针本身的值是通过值传递的方式传入函数内部的(这也是初学者容易绕晕的点),也就是说,指针传递并不是一种特殊的参数传递方式,而是一种具体的技术,这种技术依赖于宿主语言提供指针的功能。

    但是,指针传递在一定程度上具有引用传递的特性——我们可以通过对指针取内容直接操作指针引用的存储空间中的值。

  • 引用传递

    重点来了,真正的引用传递可能和大部分语言中理解的引用传递是不一样的,因为在我所知道的语言中,只有C++实现了“真引用”,而其他大部分语言的引用其实都是指针伪装的。

    C++中的引用之所以称为真引用,是因为它完全实现了“对引用的任何修改都和修改被引用者等价”,也就是说,引用实际上是一种变量别名机制,如下C++代码:

    #include <iostream>
    struct Student {
        int id;
        char name[20];
    };
    
    void rename(Student& student) {
        student = {
                10086, "laomst"
        };
        std::cout << &student << std::endl;
        student.name[0] = 'z';
    }
    
    int main() {
        Student student = {
                1, "laomst"
        };
        std::cout << &student << std::endl;
        rename(student);
    }
    

    运行上面的main函数结果如下:

    image-20220404005300309

    可见引用和被引用的变量其实就是同一个变量。在C++中使用引用需要考虑非常多的内容,比如是否会创建临时变量、引用和被引用变量的生存期等等,这里我们就不展开介绍了。

  • 半引用传递

    很多语言比如Java和C#中的引用,只是借鉴了C++中引用的特性,是通过指针来封装实现的,并不是真正的引用,如下Java代码:

    class Student {
        int id;
        String name;
        
        public Student(int id, String name) {
            this.id = id;
            this.name = name;
        }
    }
    
    class Test{
        public static rename(Studnet student) {
            student.name = "zaomst";
            student = new Student(2, "zaomst");
        }
        
        public static void main(String[] args) {
            Student student = new Student(1, "laomst");
            rename(student);
            System.out.println(student.name);
        }
    }
    

    上面的第18行代码的修改可以反映到实参上,但是上面的第19行代码,对student这个实参重新赋值是不会影响被传递进来的变量的,这一点和指针非常相似。

    其实Java中的引用本质上就是指针伪装的,怎么伪装的呢?

    首先,在语法上把定义指针的标识*给抹掉了,例如我们把上面的代码写成等价的C++代码来对比一下:

    #include <iostream>
    
    class Student {
    public:
        int id;
        std::string name;
    };
    
    void rename(Student* student) {
        (*student).name = "laomst1"; // 对应上面Java代码的第13行
        (*student) = {10086, "laomst"}; // 对应上面Java代码的第14行
    //    student -> name = "laomst1";
    }
    
    int main() {
        Student student = {1, "laomst"};
        rename(&student); // 对应上面Java代码的第19行
        std::cout << student.name << std::endl;
    }
    

    上面的C++代码其实利用指针实现了真正的引用传递特性,**但是上面Java代码和C++代码并不是完全等价的,这是因为Java在处理指针的时候,第14行代码中并没有像对应的C++中的第11行那样进行取内容操作,而是直接修改了指针值。**还有就是我们上面的C++中,创建的对象都是存储在栈中的,而Java中是不允许把对象存储在栈中的,栈中存储的永远只是对象的引用(指针)而已。通过这样的处理方式,Java让我们传递对象的方式像是引用传递,同时杜绝了在栈区创建对象的途径,让内存管理的方式更加统一。

    同时,虽然Java中的引用是基于指针封装出来的,但是它并没有直接把指针暴露给程序员,这限制了我们对指针本身进行操作(指针的加减操作)。比如我们定义了Student student = new Student();这个时候我们最多将student指向另一个Student对象或者是将它清空(student = null;),但是不能对指针本身进行操作,例如stuent++;

    所以,Java中用指针伪装出来的引用传递和真正的引用传递还是有所区别的。但是,它和C++中的引用传递在外在表现上确实差不多,那么我们也就姑且称之为引用传递了。

    那么,为什么会有引用传递呢?引用的具体作用是什么呢?这是为了节省内存,同时也确实没有必要为同一个对象创建那么多的副本,于是有了引用传递。

    但是在使用引用传递的函数的时候,引用共享内存的特点会导致内存数据可能会被经过的任何一个函数修改,所以引用传递的特性有利有弊,我们在享受效率提升的同时,也经常承受它的负面影响,这一点我们在下面介绍函数的副作用的时候会详细说明。

不同语言函数参数传递可能支持不同的方式,比如C语言中就可以使用值传递或者是指针传递,C++中支持三种方式,而Java中对于原始类型则是使用值传递的方式,对于引用类型则是“半引用传递”。经过我们的介绍之后,相信大家可以对各种参数传递方式以及它们的特性有一个比较清晰的理解。

形参和实参的绑定方式

我们在声明函数的时候,会为参数指定形式参数,在作用域规则为词法作用域的语言中,函数的形参实际上就是函数的词法作用域中的一个变量,我们在调用函数的时候必须为函数提供实际参数以初始化形式参数,否则函数将无法正常运行,这里就涉及到一个知识点——实际参数是怎么被绑定到形式参数上的呢?

在汇编中我们进行子程序的编写时传递参数的方式是在调用子程序的地方把实参挨个push到栈中,然后将CS:IP重定向到子程序的入口地址;在子程序中,会把实参挨个pop出来,这就完成的参数的传递。

根据这个原理,我们可以非常自然的想到一种实参和形参之间的匹配方式——按位置匹配。

位置匹配指的是形参在形参列表中的位置和实参在实参列表中的位置需要一一对应,来完成形参和实参的匹配,这是所有的高级语言都支持的一种形参和实参的绑定方式,如下Java代码:

public class ArgTest() {
    public static void foo(String msg, int time) {
        time = time > 0 ? time : 1;
        String str = IntStream.rangeClosed(1, time)
          .mapToObj(item -> msg)
          .collect(Collectors.joining(" "));
        System.out.println(str);
    }

    public static void main(String[] args){
        foo("hello", 3);
    }
}

image-20200826223225343

在上面的代码中,函数foo的声明public static void foo(String msg, Integer time)中形参列表是msg,time,我们在调用这个方法时foo("hello", 3)实参列表是"hello",3,根据位置匹配,那么形参msg会被赋值为"hello",而形参time会被赋值为3。

形参和实参除了按位置进行匹配之外,还可以按照键值匹配,支持这种匹配方式的语言不多,Python就支持这样的匹配方式,如下Python代码:

def func(a, b, c):
    print(a, b, c)

我们声明了一个函数func,其形参列表是a,b,c,下面三行调用func函数的代码都是正确的,且他们的运行结果都是一样的:

if __name__ == '__main__':
    func(a=1, b=2, c=3)
    func(b=2, c=3, a=1)
    func(1, c=3, b=2)
    # func(1,2,b=3) #这行代码会报错,错误信息是参数b接收了多个值

image-20200826225951193

可以看到我们在调用一个函数的时候可以显示使用键值的方式指定某一个形参的值,这与形参在形参列表中的顺序是无关的。甚至我们可以同时使用位置匹配和键值匹配的方式。实际上,Python中在声明一个函数的时候形参就被分成了很多种,不同的形参匹配规则有所不同,有的参数是只能通过键值进行匹配而不能通过位置进行匹配的。具体细节我们在介绍Python相关的文章中详细介绍。

现在我们总结一下函数的形参和实参的匹配方式:

  • 按位置匹配,这是所有的编程语言都支持的匹配方式,其对应的就是低级语言中对栈的push和pop操作
  • 按键值匹配,这种匹配方式只有部分编程语言支持。

参数默认值和可变长参数

参数的默认值顾名思义就是在声明函数的形参列表的时候为参数指定一个值,如果我们在调用的时候不进行传递,那么其就会使用这个默认值;但是参数默认值也并不是所有语言都支持的,这个特性我们在具体语言中再具体分析,这里就不做过多的介绍了。

可变长参数的意思就是,我可以为一个函数传入任意个数的实参,如下Java代码:

public static concat(int a, int b, Object ...others);

在concat函数的形参列表末尾的…代表的是我们可以输入若干的参数,个数并不固定。但是由于Java是强静态类型的编程语言,所以它要求可变长参数中所有的参数必须具有兼容的编译时类型,而对于其他类型的语言对可变长参数的类型的处理可能会有所区别。可变长参数也几乎是所有的语言都会支持的特性,这里我们也不具体进行说明了。

返回值对函数的意义

函数的返回值是函数执行完成之后所产生的值,在汇编中,我们往往会在子程序运行中把一些值放到寄存器或者是某个数据存储区域中,当执行流程从子程序返回调用方的时候,调用方会从这些寄存器或者是数据存储区域中读取这些值,这就完成了值的返回。

但是在高级语言中,为了适应更多的场景,调用方和子程序之间的数据交互一般不会占用寄存器,而是使用栈完成数据的交互(或者是某个其他约定好的数据存储区域)。

无论调用方是否需要,函数在执行的时候都会把要return的值放到约定好的数据存储区域中。而调用方如果读取了这个返回值就需要把值从那个约定好的数据存储区域复制到自己的栈帧中。

所以,在使用函数的返回值的时候,值的复制是不可避免的,如果需要复制的值很大,那么就会影响程序的性能。为了解决这个问题,我们通常通过把实际的值存储到堆中,而返回的时候只返回一个指向实际数据的指针或者引用,这样就可以大大减少数据的赋值量(但是也只是减少了复制的数据的大小,复制的动作是无法避免的)。

实际上每当我们声明一个左值,并用别的值对其进行赋值的时候,都会不可避免的发生值的复制,很多语言都针对值的复制进行了优化,其中最典型的就是C++,其引入了右值引用和移动语义。

多返回值函数

大多数的高级语言都只支持一个返回值,而有一些语言也支持多个返回值,支持多个返回值的方式也有所不同,大致有如下两种:

  • 在声明参数的时候声明参数的数据流向,是输入参数还是输出参数,还是二者皆可,这个时候所有的栈空间都是由主调方准备好让子程序直接使用,子程序会把返回值放到主调放传递过来的输出参数中,等执行流程返回主调之后,主调就可以直接使用这个输出参数了。
  • 另一种方式就是直接返回多个值,这需要语言支持多变量赋值这样的操作,比如Go语言就是其中的典型。而且Go语言中的函数往往至少具有两个返回值,第一个返回值就是通常意义上的返回值,而另一个则是代表函数的执行状态,这种机制在Go语言中经常被用来进行错误处理。

其实,就算是函数只能返回一个值,我们也可以通过返回一个自定义的数据类型的方式来间接实现多个返回值,而且在支持元组的语言中,可以非常方便的模拟支持多个返回值的函数,比如Python。

纯函数和无状态函数

我们上面介绍了函数的参数和返回值,**理想情况下,函数的参数是函数唯一的数据来源,而函数的返回值是函数唯一的输出。**这种函数是极好的,而且它有一个响亮的名字,那就是纯函数。

纯函数不引用任何的外部资源,也不会修改传入的参数或者是任何的外部资源,它有诸多的好处,尤其是在日渐盛行的函数式编程中,编写纯函数往往是我们的终极追求,纯函数就是我们理想中的函数。

但是,理想很丰满,现实很骨感,一个函数往往可能会具有多个职责,而且一个函数可能会依赖程序的上下文,这个时候,还有一类函数,它非常类似于纯函数,它就是“无状态函数”。

首先我们来定义一下什么是无状态函数:

  • 多次调用同一个函数,每次调用都是彼此独立的。每次调用不受前面调用的影响,也不会影响后面的调用。
  • 如果输入是一致的,那么输出一定是一致的。

可以看到,无状态函数其实已经具备了纯函数的形,不引用任何外部变量或资源的纯函数当然是无状态函数,但是无状态函数不一定是纯函数。因为通过定义来说,**无状态函数是可以引用外部资源或者变量的,只要这些外部资源是恒定不变的即可。**所以,想要实现无状态函数,必须满足两个重要条件:

  • 必须在函数参数中包含函数所需的所有数据。
  • 函数可以引用外部共享资源,但是这些资源必须是不变量。

在大多数的情况下,我们可以把无状态函数等价的看做是一个纯函数,因为它们具有相同的优点,如引用透明、并发安全、结果可缓存等等。

具有副作用的函数和有状态的函数

无状态函数和纯函数当然好,当时有一些函数本身就是具有状态的,比如类中定义的实例方法,它往往依赖于对象的状态,或者是一个函数会从外部读取共享变量或者是共享可变资源,当一个函数依赖于程序的状态,随着程序的运行,对函数的调用可能会发生不同的结果,这个时候我们称这个函数是具有状态的。

而且,有的时候函数会修改传入参数的状态,比如修改传入对象的属性值,而且有的时候会修改外部公共资源,这个时候我们称这个函数具有副作用。

有的函数可能会同时具有状态和副作用,最典型的还是类中的实例方法,它依赖于对象的状态,同时调用它可能会影响对象的状态。

使用具有副作用或者是有状态的函数比使用无状态函数和纯函数的要求更高,甚至有的时候我们必须对函数的实现有一定的了解才能准确无误的使用具有副作用或者是具有状态的函数。

编译器做过手脚的函数

在静态类型的编程语言中,为了让类型使用起来更加灵活,语言的编译器为我们做了非常多的工作,因为静态类型的语言要求变量必须具有一个唯一的编译时类型,所以如下Java代码:

public class Test {
  public static String convert2String(int arg) {
    return arg+"";
  }
}

上面的代码中我们调用convert2String方法的时候只能把int类型的变量转换成字符串,而对于其他的数据类型则无能为力,灵活性收到了很大的限制。这个时候,编译器通常有两种方式来提升函数的灵活性,分别是函数重载和泛型函数,下面我们就分别讨论一下。

函数重载

在很多的静态类型的语言中,都支持函数重载的功能,意思是两个同名的函数,如果具有不同的参数列表,那么编译器通过 参数调用上下文也可以推断出到底是调用了哪一个函数。

函数重载在一定程度上弥补了静态类型语言灵活性不足的问题,比如下面的Java代码就用方法重载模拟了一个可以支持任何数据类型的函数:

public class OverloadTest {
  public static String convert2String(byte arg) {
      return arg+"";
  }
  public static String convert2String(short arg) {
      return arg+"";
  }
  public static String convert2String(char arg) {
      return arg+"";
  }
  public static String convert2String(int arg) {
      return arg+"";
  }
  public static String convert2String(long arg) {
      return arg+"";
  }
  public static String convert2String(float arg) {
      return arg+"";
  }
  public static String convert2String(double arg) {
      return arg+"";
  }
  public static String convert2String(boolean arg) {
      return arg+"";
  }
  public static String convert2String(Object arg) {
      return arg == null ? "null" : arg + "";
  }
}

函数签名

静态类型的语言是通过函数签名来确定我们要调用的是哪一个函数的(在面向对象中由于要支持多态,过程要复杂很多),函数签名是指函数的名称和函数的形参列表,注意,其往往是不包含返回值和异常声明(如果语言支持的话)的。

那么为什么函数的签名中不包含返回值和异常声明呢?这是因为我们在调用函数的时候,可能并不会使用函数的返回值,而异常声明是一定不会在函数调用的时候有所体现的。也就是说函数声明中的内容,在我们调用函数的时候只有函数的名称和函数的参数是一定会出现的,所以语言的编译器或者解释器只能根据一定会出现的函数名和函数的参数列表来判断我们要使用的是哪一个函数,因此,函数签名也就只包含函数的名称和形参列表了。

泛型函数

这些年来,越来越多的语言支持泛型,泛型也慢慢由高级特性转变为普遍特性了。各种语言的泛型都差不多,我们在《静态类型语言中类型的灵活性——泛型和变型》一文中已经对泛型有过简单的介绍,这里就不再赘述了。

这里需要注意的是,泛型的工作大都是在编译期完成的,主要是为了避免我们敲打重复逻辑的代码,在runtime层其实并没有跨越式的提高。对于泛型函数也是一样的,编译器可以会给我们生成一些转型的代码,并且针对类型参数做静态类型校验,归根结底,泛型只是编译器在暗地里给我们做了很多工作,和runtime关系并不大。

动态分派函数

关于这部份内容,其实我们在《静态类型语言中类型的灵活性——泛型和变型》一文中也提到过,这里为了内容的完整性,我们做一个简单的回顾。

多分派或译多重派发(multiple dispatch)或多方法(multimethod),是某些编程语言的一个特性,其中的函数或者方法,可以在运行时(动态的)基于它的实际参数的类型,或在更一般的情况下于此之外的其他特性,动态分派。这是对单分派多态的推广,那里的函数或方法调用,基于在其上调用方法的对象的派生类型,而动态分派。多分派使用一个或多个实际参数的组合特征,路由动态分派至实现函数或方法。所谓 Single Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的编译时类型来决定。所谓 Double Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时类型来决定。举个例子:

class A {
    void foo(Object o){}
}

class B extends A {
    @Override
    void foo(Object o) {}
    void foo(String str) {}
}

上面的代码是Java的,其中B是A的子类,B重写了A中的foo(Object)方法,并且提供了foo(String)这样一个重载方法,现在我们假设Java是支持多分派的,那么如下代码:

A a = new B();
a.foo("123");

面代码的第二行应该是对B类中foo(String)方法的调用,即主调a的方法调用的是其运行时类型B中的方法,同时,会根据参数的运行时类型路由到方法foo(String)

但是,在经典的静态类型的面向对象语言中,其实只有a的实际运行类型会被分派,而参数则不会,所以在上面的例子中,a.foo("123")这行代码实际调用的其实是B类中的foo(Object)方法。因为方法选择的规则是在可用方法中选择特化程度最高的,如果一个方法重写了另一个方法那么,主调参数会选择最特化的子类型。而另一方面,为了保证类型安全,语言又得要求剩下的参数越泛化越好。而多分派不止会考虑主调参数的实际类型,其余参数的实际类型也会考虑选择最特化的。

然而不幸的是,大多数的静态类型面向对象编程语言是不支持多分派的。比如上面的Java代码,Java编译器会认为B类中的foo(String)方法是foo(Object)方法的一个重载,而a的编译时类型是类型A,类型A中没有foo(String)方法,所以在重载解析的时候就只能路由到foo(Object)方法为止,而实例方法的运行时动态绑定(多态)则把实际调用的方法路由到了B类型的foo(Object)方法中,这就是一种典型的单分派。

后面我们介绍面向对象设计模式的时候会提到一个访问者设计模式,这个设计模式其实就是在单分派语言中实现多分配的一种实践。

函数调用是表达式

在比较早期的语言中,会区分过程和函数这两个概念,过程就等价于不返回值的函数。这个时候调用过程就是语句,调用函数就是表达式。但是在C语言之后的几乎所有语言都不会区分这两个概念,把子程序统称为函数或方法。

对于静态类型的语言,声明一个不返回值的函数往往需要在声明函数的时候使用一个关键字进行标注,比如void;而对于动态类型的语言,只要不在函数中书写return语句(或者return后面不跟值)就可以了。

对于函数调用,我们应该把其看做表达式:

  • 对于静态类型的语言,因为声明函数的时候使用了关键字,而使用值的时候会进行静态类型检查,如果一个函数不返回值而我们又尝试使用它的值,那么编译器就会报告类型错误。
  • 对于动态类型的语言,如果函数没有返回值,那么函数调用表达式的计算结果往往是一个特殊的值,比如js中没有返回值的函数调用计算结果就是undefined

总之,我们可以认为函数调用永远是有值的,对于静态类型的语言有静态类型检查把关,而对于动态类型的语言则会产生一个特殊的值,也就是说,我们应该把函数调用当成一个表达式而不是一个语句。

总结

这篇文章我们介绍了高级语言对低级语言中子程序的抽象——函数;到此我们知道了,除了使用自定义数据类型来定义数据的组织方式之外,我们还可以通过定义函数来定义行为,可以让我们更好的使用数据,更好的组织逻辑和流程,提高了程序的模块化和代码的重用率。

其实,对于函数,我们还有很多的内容没有讨论到,比如异常等等,这里限于篇幅,就不再进行扩展讨论了。

到目前为止,对于高级语言中的各种元素我们介绍了个大概,那么我们应该怎样使用这些元素来编写出“人可以读懂的代码”呢?下一篇文章我们就来介绍一下在高级语言中常用的编程方法论——编程范式和编程模型。

感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

劳码识途

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值