×

从函数式角度看面向对象三大特性缺点(iOS篇)

96
FlashGeek
2017.06.08 05:46* 字数 7443

前言

我不知道你都收藏了些什么,我的阅读清单里面相当大部分都是函数式编程相关的东东:基本上是最难啃的。这些文章充斥着无比枯燥的教科书语言,我想就连那些在华尔街浸淫10年以上的大牛都无法搞懂这些函数式编程(简称FP)。你可以去花旗集团或者德意志银行找个项目经理来问问1:你们为什么要选JMS而不用Erlang?答案基本上是:我认为这个学术用的语言还无法胜任实际应用。可是,现有的一些系统不仅非常复杂还需要满足十分严苛的需求,它们就都是用函数式编程的方法来实现的。这,就说不过去了。

关于FP的文章确实比较难懂,但我不认为一定要搞得那么晦涩。有一些历史原因造成了这种知识断层,可是FP概念本身并不难理解。我希望这篇文章可以成为一个“FP入门指南”,帮助你从指令式编程走向函数式编程。先来点咖啡,然后继续读下去。很快你对FP的理解就会让同事们刮目相看了。

什么是函数式编程(Functional Programming,FP)?它从何而来?可以吃吗?倘若它真的像那些鼓吹FP的人说的那么好,为什么实际应用中那么少见?为什么只有那些在读博士的家伙想要用它?而最重要的是,它母亲的怎么就那么难学?那些所谓的closure、continuation,currying,lazy evaluation还有no side effects都是什么东东(译者:本着保留专用术语的原则,此处及下文类似情形均不译)?如果没有那些大学教授的帮忙怎样把它应用到实际工程里去?为什么它和我们熟悉的万能而神圣的指令式编程那么的不一样?

函数式概念

又称泛函编程,是一种编程范型,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及易变对象。函数编程语言最重要的基础是λ演算(lambda calculus)。而且λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。

比起命令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。结果比过程更重要

函数式编程中的函数这个术语不是指计算机中的函数(实际上是Subroutine),而是指数学中的函数,即自变量的映射。也就是说一个函数的值仅决定于函数参数的值,不依赖其他状态。比如sqrt(x)函数计算x的平方根,只要x不变,不论什么时候调用,调用几次,值都是不变的。函数式编程为我们提供了另外一种抽象和思考的方式。函数式编程是一种风格与编程语言无关, 面向对象也是一种风格与编程语言无关,两种风格并不矛盾,可以结合的- 叫 functional object(Objects in OCaml)

函数式特性

  • 闭包

    又称词法闭包(Lexical Closure)、函数闭包(function closures)或者lambdas表达式,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。

    对应于OC语言中的Blocks。

    闭包可以理解为匿名函数,后面的示例将大量使用闭包,阅读代码时,请把Block当作函数。

    因为闭包只有在被调用时才执行操作,即“惰性求值”,所以它可以被用来定义控制结构。

  • 惰性求值

    执行顺序不依赖语句顺序,更易并发。

    函数式编程语言还提供惰性求值(Lazy evaluation,也称作call-by-need),是在将表达式赋值给变量(或称作绑定)时并不计算表达式的值,而在变量第一次被使用时才进行计算。这样就可以通过避免不必要的求值提升性能。

    具体理解,参考闭包。

  • 函数是"第一等公民"

    所谓"第一等公民"(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

    比较容易理解,不举例说明。

  • 高阶函数

    高阶函数就是参数为函数或返回值为函数的函数。现象上就是函数传进传出,就像面向对象对象满天飞一样。有了高阶函数,就可以将复用的粒度降低到函数级别,相对于面向对象语言,复用的粒度更低。

    这时候我们关注函数函数之间的关系。

    举例来说,假设有如下的三个函数,

    // 例子1:
    typedef NSInteger(^BlockFun)(NSInteger);
    
    BlockFun retSelf = ^(NSInteger a){ return a;};
    BlockFun square = ^(NSInteger a){ return a*a;};
    BlockFun cube = ^(NSInteger a){ return a*a*a;};
    
    NSInteger sumInt(NSInteger a, NSInteger b){
        if (a > b) return 0;
        return (a + sumInt(a + 1, b));
    }
    
    NSInteger sumSquare(NSInteger a, NSInteger b){
        if (a > b) return 0;
        return (square(a) + sumSquare(a + 1, b));
    }
    
    NSInteger sumCube(NSInteger a, NSInteger b){
        if (a > b) return 0;
        return (cube(a) + sumCube(a + 1, b));
    }
    

    分别是求a到b之间整数之和,求a到b之间整数的平方和,求a到b之间整数的立方和。

    三个函数不同的只是其中的fun不同,那么是否可以抽象出一个共同的模式呢?

    我们可以定义一个高阶函数sumWithFun:

    NSInteger sumWithFun(BlockFun fun , NSInteger a, NSInteger b){
        if (a > b) return 0;
        return (fun(a) + sumWithFun(fun, a + 1, b));
    }
    

    其中参数fun是一个函数,在函数中调用fun函数进行计算,并进行求和。

    然后例子1就可以简化如下:

    // 例子1:
    typedef NSInteger(^BlockFun)(NSInteger);
    
    BlockFun retSelf = ^(NSInteger a){ return a;};
    BlockFun square = ^(NSInteger a){ return a*a;};
    BlockFun cube = ^(NSInteger a){ return a*a*a;};
    NSInteger sumWithFun(BlockFun fun , NSInteger a, NSInteger b){
        if (a > b) return 0;
        return (fun(a) + sumWithFun(fun, a + 1, b));
    }
    // 调用方式
    NSInteger a = 10, b = 20;
    NSLog(@"%ld, %ld, %ld",
          sumWithFun(retSelf, a, b),
          sumWithFun(square, a, b),
          sumWithFun(cube, a, b)
          );
    

    这样就可以重用sumWithFun函数来实现三个函数中的求和逻辑。
    (示例来源:https://d396qusza40orc.cloudfront.net/progfun/lecture_slides/week2-2.pdf

    高阶函数提供了一种函数级别上的依赖注入(或反转控制)机制,在上面的例子里,sumWithFun函数的逻辑依赖于注入进来的函数的逻辑。很多GoF设计模式都可以用高阶函数来实现,如Visitor,Strategy,Decorator等。比如Visitor模式就可以用集合类的map()或foreach()高阶函数来替代。

  • Continuation

    我们对函数的理解只有一半是正确的,因为这样的理解基于一个错误的假设:函数一定要把其返回值返回给调用者。按照这样的理解,continuation就是更加广义的函数。这里的函数不一定要把返回值传回给调用者,相反,它可以把返回值传给程序中的任意代码。continuation就是一种特别的参数,把这种参数传到函数中,函数就能够根据continuation将返回值传递到程序中的某段代码中。说得很高深,实际上没那么复杂。直接来看看下面的例子好了:

    int i = add(5, 10);
    int j = square(i);
    

    add这个函数将返回15然后这个值会赋给i,这也是add被调用的地方。接下来i的值又会被用于调用square。请注意支持惰性求值的编译器是不能打乱这段代码执行顺序的,因为第二个函数的执行依赖于第一个函数成功执行并返回结果。这段代码可以用Continuation Pass Style(CPS)技术重写,这样一来add的返回值就不是传给其调用者,而是直接传到square里去了。

    int j = add(5, 10, square);
    

    在上例中,add多了一个参数:一个函数,add必须在完成自己的计算后,调用这个函数并把结果传给它。这时square就是add的一个continuation。上面两段程序中j的值都是225。

    这样,我们学习到了强制惰性语言顺序执行两个表达式的第一个技巧。再来看看下面IO程序(是不是有点眼熟?):

    System.out.println("Please enter your name: ");
    System.in.readLine();
    

    这两行代码彼此之间没有依赖关系,因此编译器可以随意的重新安排它们的执行顺序。可是只要用CPS重写它,编译器就必须顺序执行了,因为重写后的代码存在依赖关系了。

     System.out.println("Please enter your name: ", System.in.readLine);
    
  • 柯里化(局部调用(partial application))

    维基百科定义:

    是把接受多个参数函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

    当一个函数没有传入全部所需参数时,它会返回另一个函数(这个返回的函数会记录那些已经传入的参数),这种情况叫作柯里化。

    在直觉上,柯里化声称“如果你固定某些参数,你将得到接受余下参数的一个函数”。
    比如幂函数pow(x, y),它接受两个参数——x和y,计算x^y。使用柯里化技术,可以将y固定为2转化为只接受单一参数x的平方函数,或者将y固定为3转化为立方函数。代码如下:

    // 平方函数
    double(^SquareFun)(double) = ^(double a){
        return pow(a, 2);
    };
    
    // 立方函数
    double(^CubeFun)(double) = ^(double a){
        return pow(a, 3);
    };
    

    熟悉设计模式的朋友已经感觉到,currying完成的事情就是函数(接口)封装,它将一个已有的函数(接口)做封装,得到一个新的函数(接口),这与适配器模式(Adapter pattern)的思想是一致的。

    为什么要柯里化:

    • 延迟计算。上面的例子已经比较好地说明了。

    • 参数复用。当在多次调用同一个函数,并且传递的参数绝大多数是相同的,那么该函数可能是一个很好的柯里化候选。

    • 动态创建函数。这可以是在部分计算出结果后,在此基础上动态生成新的函数处理后面的业务,这样省略了重复计算。或者可以通过将要传入调用函数的参数子集,部分应用到函数中,从而动态创造出一个新函数,这个新函数保存了重复传入的参数(以后不必每次都传)。

思考:高阶函数中举的例子sumWithFun如何柯里化?
  • 只用"表达式",不用"语句"

    "表达式"(expression)是一个单纯的运算过程,总是有返回值;"语句"(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。

    原因是函数式编程的开发动机,一开始就是为了处理运算(computation),不考虑系统的读写(I/O)。"语句"属于对系统的读写操作,所以就被排斥在外。

    当然,实际应用中,不做I/O是不可能的。因此,编程过程中,函数式编程只要求把I/O限制到最小,不要有不必要的读写行为,保持计算过程的单纯性。

    一切皆表达式思维:if b then 100 else 10,这不是条件跳转,而是一个三元表达式,返回100或者10。

  • 不修改状态

    上一点已经提到,函数式编程只是返回新的值,不修改系统变量。因此,不修改变量,也是它的一个重要特点。
    在其他类型的语言中,变量往往用来保存"状态"(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。下面的代码是一个将字符串逆序排列的函数,它演示了不同的参数如何决定了运算所处的"状态"。

    let a = 100,意义不是把100赋值给变量a,而是把a符号绑定(或者叫匹配)到100。

    由于变量值是不可变的,对于值的操作并不是修改原来的值,而是修改新产生的值,原来的值保持不便。例如一个Point类,其moveBy方法不是改变已有Point实例的x和y坐标值,而是返回一个新的Point实例。

    class Point(x: Int, y: Int){
        override def toString() = "Point (" + x + ", " + y + ")"
        def moveBy(deltaX: Int, deltaY: Int) = {
            new Point(x + deltaX, y + deltaY)
        }
    } 
    

    (示例来源:Anders Hejlsberg在echDays 2010上的演讲)

    同样由于变量不可变,纯函数编程语言无法实现循环,这是因为For循环使用可变的状态作为计数器,而While循环或DoWhile循环需要可变的状态作为跳出循环的条件。因此在函数式语言里就只能使用递归来解决迭代问题,这使得函数式编程严重依赖递归。

    通常来说,算法都有递推(iterative)和递归(recursive)两种定义,以阶乘为例,阶乘的递推定义为:
    而阶乘的递归定义
    递推定义的计算时需要使用一个累积器保存每个迭代的中间计算结果,C代码如下:

    static int fact(int n){
      int acc = 1;
      for(int k = 1; k <= n; k++){
        acc = acc * k;
      }
      return acc;
    }
    

    而递归定义的计算的C代码如下:

    int fact(int n){
      if(n == 0) return 1
      return n * fact(n-1)
    }
    

    我们可以看到,没有使用循环,没有使用可变的状态,函数更短小,不需要显示地使用累积器保存中间计算结果,而是使用参数n(在栈上分配)来保存中间计算结果。
    (示例来源:1. Recursion

  • pipeline

    这个技术的意思是,把函数实例成一个一个的action,然后,把一组action放到一个数组或是列表中,然后把数据传给这个action list,数据就像一个pipeline一样顺序地被各个函数所操作,最终得到我们想要的结果,如下的StringFunCompose函数。

    typedef NSString*(^StringBlock)(NSString*);
    
    StringBlock ToUpperCase =  ^(NSString*str){
        return [str uppercaseString];
    };
    
    StringBlock Excailm = ^(NSString*str){
        return [str stringByAppendingString:@"!"];
    };
    
    ////  可读性不好,让代码从右向左运行,而不是由内而外运行
    StringBlock Shout = ^(NSString* str){
        return Excailm(ToUpperCase(str));
    };
    
    StringBlock StringFunCompose(NSArray*blocks)
    {
        return ^(NSString* str){
            NSEnumerator *enumerator = [blocks reverseObjectEnumerator];
            StringBlock block;
            while (block = [enumerator nextObject]) {
                str = block(str);
            }
            return str;
        };
    }
    
    void sampleCurry(){
        NSLog(@"[sampleCurry]");
        NSLog(@"%@", Shout(@"Let's get started with FP"));
        NSLog(@"%@", StringFunCompose(@[Excailm, ToUpperCase])(@"Let's get started with FP"));
    }
    

函数式与面向对象、面向过程区别

面向对象编程也是一种命令式编程。

命令式编程是面向计算机硬件的抽象,有变量(对应着存储单元),赋值语句(获取,存储指令),表达式(内存引用和算术运算)和控制语句(跳转指令),一句话,命令式程序就是一个冯诺依曼机的指令序列。面向对象只是建模方式不同,实质也是一种命令式编程。

维基百科上说,函数式编程声明式编程的一种。而声明式编程则是与命令式编程相对的反义词。

  • 抽象思维方式

    函数式编程关心数据的映射,命令式编程关心解决问题的步骤

    函数式考虑数据的变换过程,A->B->C->D->E;而面向对象,考虑,我应该有哪些对象,每个对象实现哪些过程函数,过程之间如何协作。

  • 变量无状态,没有赋值

    纯函数式编程语言中的变量也不是命令式编程语言中的变量,即存储状态的单元,而是代数中的变量,即一个值的名称。变量的值是不可变的(immutable),也就是说不允许像命令式编程语言中那样多次给一个变量赋值。比如说在命令式编程语言我们写“x = x + 1”,这依赖可变状态的事实,拿给程序员看说是对的,但拿给数学家看,却被认为这个等式为假。

函数式的优点

  • 没有"副作用"

    所谓"副作用"(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。
    函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

  • 函数引用透明,是纯函数

    引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

    有了不修改变量特性,这点是很显然的。其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。这就叫"引用不透明",很不利于观察和理解程序的行为。

  • 可移植性/自文档化(Portable / Self-Documenting)

    由于不依赖于函数外的变量,因此可移植性好;纯函数是完全自给自足的,它需要的所有东西都能轻易获得。仔细思考思考这一点...这种自给自足的好处是什么呢?首先,纯函数的依赖很明确,因此更易于观察和理解——没有偷偷摸摸的小动作。

  • 代码简洁,开发快速

    函数式编程大量使用函数,减少了代码的重复,因此程序比较短,开发速度较快。

  • 复用粒度最小

    函数式的利用粒度更小,以函数为单位。

  • 接近自然语言,易于理解

    函数式编程的自由度很高,可以写出很接近自然语言的代码。

    前文曾经将表达式(1 + 2) * 3 - 4,写成函数式语言:

      subtract(multiply(add(1,2), 3), 4)
    

    对它进行变形,不难得到另一种写法:

      add(1,2).multiply(3).subtract(4)
    

    ​ 这基本就是自然语言的表达了。再看下面的代码,大家应该一眼就能明白它的意思吧:

      merge([1,2],[3,4]).sort().search("2")
    

  • 易于测试,不容易出错

    函数即不依赖外部的状态也不修改外部的状态,函数调用的结果不依赖调用的时间和位置,这样写的代码容易进行推理,不容易出错。这使得单元测试和调试都更容易。

    程序中的状态不好维护,在并发的时候更不好维护。(你可以试想一下如果你的程序有个复杂的状态,当以后别人改你代码的时候,是很容易出bug的,在并行中这样的问题就更多了)

  • 易于"并发编程"

    函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)。

    请看下面的代码:

      var s1 = Op1();
      var s2 = Op2();
      var s3 = concat(s1, s2);
    

    由于s1和s2互不干扰,不会修改变量,谁先执行是无所谓的,所以可以放心地增加线程,把它们分配在两个线程上完成。其他类型的语言就做不到这一点,因为s1可能会修改系统状态,而s2可能会用到这些状态,所以必须保证s2在s1之后运行,自然也就不能部署到其他线程上了。

    多核CPU是将来的潮流,所以函数式编程的这个特性非常重要。

  • 代码的热升级

    函数式编程没有副作用,只要保证接口不变,内部实现是外部无关的。所以,可以在运行状态下直接升级代码,不需要重启,也不需要停机。Erlang语言早就证明了这一点,它是瑞典爱立信公司为了管理电话系统而开发的,电话系统的升级当然是不能停机的。

函数式的缺点

  • 不擅长处理可变状态和IO

    处理可变状态和处理IO,要么引入可变变量,要么通过Monad来进行封装(如State Monad和IO Monad)。

    System.out.println("Please enter your name: ");
    System.in.readLine();
    

    在惰性语言中没人能保证第一行会中第二行之前执行!这也就意味着我们不能处理IO,不能调用系统函数做任何有用的事情(这些函数需要按照顺序执行,因为它们依赖于外部状态),也就是说不能和外界交互了!如果在代码中引入支持顺序执行的代码原语,那么我们就失去了用数学方式分析处理代码的优势(而这也意味着失去了函数式编程的所有优势)。

从函数式角度看面向对象

​ 看看维基百科中声明式编程的定义:

  • 声明式编程是告诉计算机需要计算“什么”而不是“如何”去计算

  • 任何没有副作用的编程语言,或者更确切一点,任何引用透明的编程语言

  • 任何有严格计算逻辑的编程语言

    我个人理解,这三个特点中的前两个都是为了模仿人类的思维方式。

  • 因为人类思考主要靠直觉,人类自己也搞不清楚自己怎么想出答案的。所以声明式编程语言也不要规定电脑具体怎么执行命令。

  • 因为人类很固执,脑海中已有的概念很难修改。所以声明式编程也要模仿人类,不允许修改变量。

  • 第三点则是人工智能先驱的美好愿望,像人类一样思考,但是别像人类一样犯错。

  • 抽象方式

    • 面向对象之间需要协作,导致对象之间关系错综复杂,不好理清。

      建议:

      -[x] 可以将功能抽象成对象,但抽象的基础是数据对象的映射,每个对象只实现自己的功能,和其它对象没有任何耦合。
      -[x] 流程图上的一个节点抽象成一个对象,最终有一个对象或者函数负责将所有的对象连接在一起,完成整个流程。这时候,可以从这个对象或者函数可以清晰地看到整个流程

  • 三大特性

    • 封装

      • 可读性差、调试困难

      面向对象思想将变量封装到类里,然后使用函数对其进行操作,成员变量的作用域在整个类,需要考虑变量随着时间的变化。导致可读性差,调试难度。

      建议:

      ​ 尽量少地使用成员变量,变量的作用范围控制到最小

      • api使用困难

      由于函数和类成员变量偶合,在调用某个函数之前可能要先调用其它函数,如果顺序不对,就可能导致异常。

      建议:

      -[x] 尽量使每个函数不引用成员变量和全局变量,使函数无副作用,引用透明。
      -[x] 界面类只是简单地响应用户事件及显示数据,尽量不要有逻辑。

  • 继承

    • 需要理解继承层次间类的关系,可读性差,继承层次大于3层时,就非常难理解。

    • 使变量和函数的作用域扩大,使耦合度变大,可读性进一步降低。

    • 使函数的作用域扩大,导致一些函数覆盖问题。

    建议:

    -[x] 不使用类

-[x] 少用继承,或者不继承,使用了继承,层次尽量不要超过3层
-[x] 多使用组合,少用继承
  • 多态

    • 调试困难,需要运行时,才能确定具体哪个类实现了哪个函数。

    建议:

    -[x] 使用高阶函数和柯里化实现差异化

为什么函数式编程这么多优点,没有流行起来

  • 刚开始只是为了数学计算,有些场景无法实现,因此不适合实际工作应用,特别是对界面的处理。不过也有用函数式实现界面的,比如om
  • 由于大量使用递归,那时候的编译器没做优化,导致运行缓慢。

不仅最古老的函数式语言Lisp重获青春,而且新的函数式语言层出不穷,比如Erlang、clojure、Scala、F#等等。目前最当红的Swift、C++、Objective-C、C#、Python、Ruby、Javascript,对函数式编程的支持都很强,就连老牌的面向对象的Java、面向过程的PHP,都忙不迭地加入对匿名函数的支持。越来越多的迹象表明,函数式编程已经不再是学术界的最爱,开始大踏步地在业界投入实用。

也许继"面向对象编程"之后,"函数式编程"会成为下一个编程的主流范式(paradigm)。未来的程序员恐怕或多或少都必须懂一点。

java也在努力的改革,进步,在像函数式“进化”,比如说java8提供的Stream流,lambda,努力将函数(方法)提升为一等公民,Stream中的透明化,无态化都流露着函数式的思想。虽然java的fp现在可能走得是oop的极端表现形式,但是也从另一个侧面表达了fp的优点和将来大势所在。

作者:Accelerator链接:https://www.zhihu.com/question/30190384/answer/142902047来源:知乎著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

现在流行起来,个人认为有以下几个原因:

  • 计算机硬件,多核技术发展使性能不再是瓶颈
  • fp编译器的进步
  • 移动互联网时代,分布式、并发应用程序的时代终于来临了
  • 开源库ReactiveCocoa,RxJava及前端Redux使用了大量函数式的编程思想。
  • 大数据,人工智能的发展导致只对数据进行处理。
函数式
Web note ad 1