再谈编程范式(3):理解面向过程/面向对象/函数式编程的精髓

面向过程(PO)

面向过程是随着VB一起来到我的世界,那个时候会的非常有限,感觉能把程序写出来自己就非常棒了,VB是做那种可视化界面,在工具栏拖个框框放到面板上,然后就在各个事件上写完整的逻辑,什么封装,抽象,继承一概不懂,就有一种一个方法把实现过程需要的逻辑都罗列了,面向过程分析的是步骤。这样说过于抽象,举个例子,洗衣机洗衣服。

1、打开洗衣机

2、放入衣服

3、放入洗衣液

4、关上洗衣机

拆分流程,完成这件事情,都做了哪些流程,不关心谁做的。这样做行不行,首先肯定没问题,但是有什么问题呢?如果在洗衣服的流程中加个柔顺剂,那么这个洗衣服的流程都存在被改动的风险,即可维护性低,不易扩展,不容易复用。

简单来说面向过程,自顶向下,逐步细化!面向过程,就是按照我们分析好了的步骤,按部就班的依次执行就行了!所以当我们用面向过程的思想去编程或解决问题时,首先一定要把详细的实现过程弄清楚。一旦过程设计清楚,代码的实现简直轻而易举

面向过程是一种最为实际的一种思考方式,就算是面向对象的方法也是含有面向过程的思想,可以说面向过程是一种基础的方法,他考虑的是实际的实现,面向过程是从上往下步步求精。所以面向过程最重要的是模块化的思想方法,面向对象的方法主要是把事务给对象化,对象包括属性和行为,当程序规模不是很大时,面向过程的方法还会体现出一种优势,程序的流程会特别清楚,按着模块与函数的方法可以很好的组织

面向对象(OOP)

面向对象则是随着.Net和Java一起来到我的世界,这个时候已经知道面向过程存在一些问题,也学习过设计模式了,知道程序设计七大原则。

1、单一职责、2、开闭原则、3、里氏替换、4、依赖倒置、5、接口隔离、6、迪米特法则、7、合成复用

也知道面向对象的三大特征,封装,继承,多态。

也知道何为对象?现实世界中,任何一个操作或者是业务逻辑的实现都需要一个实体来完成,也就是说,实体就是动作的支配者,没有实体,就肯定没有动作发生,其实对应到程序世界,实体即对象,对象由属性和方法组成,例如人属性则指身高,体重之类特征性内容,而方法则指能做什么。面向对象把问题看作由对象的属性与对象所进行的行为组成。基于对象的概念,以类作为对象的模板,把类和继承作为构造机制,以对象为中心,来思考并解决问题

有了这些理论该怎么解决面向过程中存在问题呢?接着上边的案例,洗衣机洗衣服,主要涉及两个对象,洗衣机,有两个方法打开洗衣机,关上洗衣机。而人则有三个方法,放衣服,放洗衣液。使用面向对象编程方式

1、洗衣机.打开洗衣机

2、人.放衣服

3、人.放洗衣液

4、洗衣机.关上洗衣机

从编程上区别,就是对象成为了方法的执行者,每个流程的执行都需要一个对象,也就是代码中的类。这样的好处就是,刚才在面向过程中想加入柔顺剂的过程非常简单,在人这个对象中添加个方法即可,就是经常说高耦合低内聚,也变的更加容易维护,拓展,复用也变的容易。

所谓的面向对象,就是在编程的时候尽可能的去模拟真实的现实世界,按照现实世界中的逻辑去处理一个问题,分析问题中参与其中的有哪些实体,这些实体应该有什么属性和方法,我们如何通过调用这些实体的属性和方法去解决问题。

OOP 举例

// 这是初始版本
public class IncomeTaxCalculator{
  protected double _threshold = 3500;

  public double calculate(IncomeRecord record){
    double tax = record.salary <= _threshold ? 0 : (record.salary - _threshold) * 0.2;
    return tax;
  }
}

// 往往 Value Object 一旦发布基本上就很难改变,因为外部已经有很多引用
class IncomeRecord{
    String id; // 身份证号
    String name; // 姓名
    double salary; // 工资
}

// 当需求改变时 OOP 的处理方法
public class IncomeTaxCalculatorV2018 extends IncomeTaxCalculator{

  // 2018年9月1号后起征点调整到了 5000,重写 calculate method 加上这个逻辑
  public double calculate(IncomeRecord record){
    if(today() > date(2018, 9, 1)){
      double _threshold = 5000;
    }
    return super.calculate(record);
  }
}

IncomeTaxCalculator calculator = new IncomeTaxCalculator();
calculator.calculate(new IncomeRecord(1234, 'tiger', 10000));

// 需求改变后,只需要使用新的 class 即可:
IncomeTaxCalculator calculator2018 = new IncomeTaxCalculatorV2018();
calculator2018.calculate(new IncomeRecord(1234, 'tiger', 10000));

从以上例子可以看出来原来的 class 完全不需要任何改动,有任何的新需求只需要新增一个 subclass 继承原来的 IncomeTaxCalculator 即可。

不可否认,OOP 对可维护性有非常好的支持,把可维护性带到了一个新的高度。但也有一些弊端。

  1. subclass IncomeTaxCalculatorV2018.calculate() 包含了 today(),即 side effect,如果不这么做,那就需要改变 IncomeRecord,即 input

  2. parent class 内部变量 _threshold 发生了改变

  3. 继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。具体参看《理论七:为何说要多用组合少用继承?如何决定该用组合还是继承? 》

如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系

对于JavaScript的基础,其是基于原型链继承

2.jpg

更加复杂一些。

来自游戏公司GameSys的Yan Cui发表了博文:《This is why you need Composition over Inheritance》使用了一个很好的案例来说明在实践中如何使用组合。

EventSourcing/CQRS的倡导者Greg Young还指出,问题域的分解是我们当前软件工业的最大问题。

问题域的分解不只是局限于代码组织,微服务也是一个这方面的典型案例,从巨石monolithic铁板一块哦系统迁移到微服务是另外一种问题域的解耦。

因此,我们需要使用利刀分解前面描述的类层次树形结构,使用更小的、可组合的替换它们,包括使用这种特点编程范式-函数式编程,这类语言-GO、F

函数式编程(FP)

这个函数源于数学里的函数,因为它的起源是数学家Alonzo Church发明的Lambda演算(Lambda calculus,也写作 λ-calculus)。所以,Lambda这个词在函数式编程中经常出现,可简单理解成匿名函数。

函数式编程函数式定义

和面向对象相比,它要规避状态和副作用,即同样输入一定会给出同样输出

虽然函数式编程语言早就出现,但函数式编程概念却是John Backus在其1977 年图灵奖获奖的演讲上提出。

随着函数式编程这几年蓬勃的发展,越来越多的“老”程序设计语言已经在新的版本中加入了对函数式编程的支持。所以,如果你用的是新版本,可以不必像我写得那么复杂。

In computer science,functional programmingis aprogramming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data.

看了以上的定义,我对 FP 函数式编程的理解主要有两点:

  • 不改变 input

  • 没有 side effect

和面向对象编程(object-oriented programming,简称 OOP)最大的区别就在于,OOP 里子类会继承、改变父类的状态,并且很多时候 method 不是 pure function,会有很多 side effect 产生。

函数式编程

函数式编程,大量使用函数,减少代码重复,提升开发效率;接近自然语言,易于理解;因为不依赖外界状态,只要给定输入参数,结果必定相同,方便代码管理;因为不存在修改变量,天生更易于并发,也能理解,GO语言默认是传值的。

1、函数式编程的显著特征-不可变|无副作用|引用透明

在函数式编程中,一个变量一旦被赋值,是不可改变的。没有可变的变量,意味着没有状态。而中间状态是导致软件难以管理的一个重要原因,尤其在并发状态下,稍有不慎,中间状态的存在很容易导致问题。没有中间状态,也就能避免这类问题。无中间状态,更抽象地说是没有副作用。说的是一个函数只管接受一些入参,进行计算后吐出结果,除此以外不会对软件造成任何其他影响,把这个叫做没有副作用因为没有中间状态,因此一个函数的输出只取决于输入,只要输入是一致的,那么输出必然是一致的。这个又叫做引用透明

函数式编程特征

2、函数式编程的目标 - 模块化

结构化编程和非结构化编程的区别,从表面上看比较大的一个区别是结构化编程没了“goto”语句。但更深层次是结构化编程使得模块化成为可能。

像goto语句这样的能力存在,虽然会带来一定的便利,但是它会打破模块之间的界限,让模块化变得不容易。

模块化有诸多好处,首先模块内部是更小的单一的逻辑,更容易编程;其次模块化有利于复用;最后模块化使得每个模块也更加易于测试。

模块化是软件成功的关键所在,模块化的本质是对问题进行分解,针对细粒度的子问题编程解决,然后把一个个小的解决方案整合起来,解决完整的问题。这里就需要一个机制,可以将一个个小模块整合起来。函数式编程有利于小模块的整合,有利于模块化编程

3、将函数整合起来 - 高阶函数(Higher-order Functions)

高阶函数的定义。满足以下其中一个条件即可称为高阶函数:

  • 接受一个或者多个函数作为其入参(takes one or more functions as arguments)

  • 返回值是一个函数 (returns a function as its result)

假如我们需要计算出学校中所有女生的成绩,和所有女老师的年龄。传统的编程方式我们是这样做的:

//用函数式编程的方式求解,可以这样做:
 
//求所有女生的成绩
List<Integer> grades = students.stream().filter(s -> s.sex.equals("femail")).map(s -> {return s.grade}).collect(Collectors.toList());
 
//求所有女老师的年龄
List<Integer> ages = teachers.stream().filter(t -> t.sex.equals("femail")).map(t -> {return t.age}).collect(Collectors.toList());

例子中使用的是比较著名的高阶函数,map, filter,此外常听到的还有reduce。这些高阶函数将循环给抽象了。map,filter里面可以传入不同的函数,操作不同的数据类型。但高阶函数本身并不局限于map,reduce,filter,满足上述定义的都可以成为高阶函数。高阶函数像骨架一样支起程序的整体结构,具体的实现则由作为参数传入的具体函数来实现。因此,我们看到高阶函数提供了一种能力,可以将普通函数(功能模块)整合起来,使得任一普通函数都能被灵活的替换和复用。

高阶函数-函数式编程应用

组合与管道

组合函数,目的是将多个函数组合成一个函数

举个简单的例子:

function afn(a){
    return a*2;
}
function bfn(b){
    return b*3;
}
const compose = (a,b)=>c=>a(b(c));
let myfn =  compose(afn,bfn);
console.log( myfn(2));

可以看到compose实现一个简单的功能:形成了一个新的函数,而这个函数就是一条从 bfn -> afn 的流水线

下面再来看看如何实现一个多函数组合:

const compose = (...fns)=>val=>fns.reverse().reduce((acc,fn)=>fn(acc),val);

compose执行是从右到左的。而管道函数,执行顺序是从左到右执行的

const pipe = (...fns)=>val=>fns.reduce((acc,fn)=>fn(acc),val);

组合函数与管道函数的意义在于:可以把很多小函数组合起来完成更复杂的逻辑

柯里化

柯里化是把一个多参数函数转化成一个嵌套的一元函数的过程

一个二元函数如下:

let fn = (x,y)=>x+y;

转化成柯里化函数如下:

const curry = function(fn){
    return function curriedFn(...args){
        if(args.length<fn.length){
            return function(){
                return curriedFn(...args.concat([...arguments]));
            }
        }
        return fn(...args);
    }
}
const fn = (x,y,z,a)=>x+y+z+a;
const myfn = curry(fn);
console.log(myfn(1)(2)(3)(1));

关于柯里化函数的意义如下:

• 让纯函数更纯,每次接受一个参数,松散解耦

• 惰性执行

 

 

 

4、惰性计算

除了高阶函数和仿函数(或闭包)的概念,还引入了惰性计算的概念。

在惰性计算中,表达式不是在绑定到变量时立即计算,而是在求值程序需要产生表达式的值时进行计算。延迟的计算使您可以编写可能潜在地生成无穷输出的函数。因为不会计算多于程序的其余部分所需要的值,所以不需要担心由无穷计算所导致的 out-of-memory 错误。一个惰性计算的例子是生成无穷 Fibonacci 列表的函数,但是对第n个Fibonacci 数的计算相当于只是从可能的无穷列表中提取一项。

5、函数是一等公民(first-class citizen

函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)

  • 可按需创建

  • 可存储在数据结构中

  • 可以当作实参传给另一个函数

  • 可当作另一个函数的返回值

对象,是OOP语言的一等公民,它就满足上述所有条件。所以,即使语言没有这种一等公民的函数,也完全能模拟(之前就用Java对象模拟出一个函数Predicate)。

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

举例来说,下面代码中的print变量就是一个函数,可以作为另一个函数的参数。

var print = function(i){ console.log(i);};
[1,2,3].forEach(print);

看待函数式编程,如果只看到一些具体的特性,像map,reduce,缓求值等等,就会觉得不过如此,甚至觉得不过是把一些常用的逻辑整理了一下而已,那就错过了函数式编程的精彩。我们需要从函数式编程的思想基石--基于函数构建软件,以及函数式编程对于模块化的益处,我们就能看到函数式编程思想的魅力。

 

FP 举例

// 初始方法
function calculator(record){
  const threshold = 3500;
  return record.salary <= threshold ? 0 : (record.salary - _threshold) * 0.2;
}

// 应对需求,新增的计算方法
function calculatorV2018(record){
  const threshold = 5000;
  return record.salary <= threshold ? 0 : (record.salary - _threshold) * 0.2;
}

// 高阶函数 higher-order function,包装之前的函数
function getCalculator(oldFn, newFn, today){
  if(today() > date(2018, 9, 1)){
    return newFn;
  }else{
    return oldFn;
  }
}

calculator(new IncomeRecord(1234, 'tiger', 10000));
// 需求改变后,用高阶函数包装之前的函数
const taxCalculatorV2018 = getCalculator(calculator, calculatorV2018, new Date(2018, 9, 1));
taxCalculatorV2018(new IncomeRecord(1234, 'tiger', 10000));

尽管在OOP中可以创建纯函数,但它并不是这种范式的主要焦点,因为它的主要单元是对象,而对象的设计又是为了与对象的状态进行交互。

纯函数是非常简单和可重用的代码块,在实现一个程序时可以非常实用。因此,函数是函数式编程的主要单元是非常合理的。

  • 良好的可读性和理解力,因为它们是原子性的。

  • 纯函数是跨分布式计算集群和CPU并行处理的良好解决方案。

  • 由于纯函数是独立的,所以在代码中重构和重组它们更容易。另外,独立于外部也使它们更具有可移植性,更容易在其他应用程序中重复使用。

  • 纯函数可以很容易地被测试,考虑到所需要的只是测试输入和确认(预期)结果。

纯函数的缺点是,它将操作置于数据之上。如果一个纯函数只产生与输入相同的输出,那么它就不能返回其他不同的(也许是有意义的)值。由于这个原因,函数式编程具有极强的操作性、实用性,而且正如其名称所示,是功能性的。

面向对象的编程在很大程度上依赖于类和对象的概念,而类和对象又包含函数和数据。正如所解释的,类是一个既定的蓝图(或原型),对象就是从这个蓝图中建立起来的。因此,类代表了某一对象类型所共有的一组方法(或属性)。反过来,一个对象是OOP的基本单位,代表现实生活中的实体。一个对象必须有。

  • 一个身份一个唯一的名字;拥有一个唯一的ID可以使对象与其他对象进行交互。

  • 一个状态一个对象的状态反映了一个对象的属性或特性。

  • 行为一个对象的方法,以及对象将如何响应并与其他对象互动。

例如,让我们想象一下,我们有 "运动员1 "这个对象,在这个对象中,我们通过属性拥有关于这个对象的所有数据。因此,状态可以是运动、身高、体重、奖杯、国家等等。这些属性存储了数据,而一个对象的数据可以通过归属于一个对象的函数来操作。在这种情况下,这个对象的方法可以是攻击、防御、跳跃、跑步、冲刺等。此外,开发者可以通过在对象的代码模块中声明变量来创建属性。

总之,在OOP语言中,数据被存储在属性中,而背后的逻辑在于函数和各自的方法中。关于面向对象的编程,方法是属于一个类或对象的功能;方法是由一个特定的类甚至对象**"拥有"**。相比之下,函数是 "自由 "的,意味着它们可以在代码的任何其他范围内,不属于类或对象。

因此,一个方法总是一个函数,但一个函数不总是一个方法。当对象包含紧密合作的属性和方法时,这些对象属于同一个类。

在OOP语言中,编写代码是为了定义类,并由此定义各自的对象。纯粹的面向对象语言遵循四个核心原则:封装、抽象、继承和多态性。

The four OOP Principles

可变的与不可变的

面向对象编程可以支持可变数据。相反,函数式编程则使用不可变的数据。在这两种编程范式中

  • 不可变的对象指的是一个一旦创建就不能修改其状态的对象。

  • 可变的对象则正好相反;一个对象的状态甚至在创建后也可以被修改。

在纯函数式编程语言(例如Haskell)中,不可能创建可变的对象。因此,对象通常是不可变的。在OOP语言中,答案并不那么直接,因为它更多地取决于每种OOP语言的规范。为了提高运行时的效率以及可读性,字符串和具体对象可以被表达为不可变的对象。另外,在处理多线程应用程序时,不可变的对象会非常有帮助,因为它避免了数据被其他线程改变的风险。

可变对象也有其优势

它们允许开发者直接在对象中进行修改,而不需要分配对象,从而节省了时间,加快了项目的进度。然而,这要由开发者和开发团队根据项目的目标来决定它是否真的有回报。例如,变异也会为bug打开更多的大门,但有时它的速度是非常合适的,甚至是必要的。

因此,OOP可以支持可变性,但其语言也可能允许不可变性。Java、C++、C#、Python、Ruby和Perl可以被认为是面向对象的编程语言,但它们并不完全支持可变性或不可变性。例如,在Java中,字符串是不可变的对象。尽管如此,Java也有字符串的可变版本。同样地,在C++中,开发者可以将新的类实例声明为不可变的或可变的。另一个很好的例子是Python,它的内置类型是不可变的(例如,数字、布尔、frozensets、字符串和图元);然而,自定义类通常是可变的。

同样重要的是要记住,许多提到的语言不是100%的函数式编程或面向对象。例如,Python是最流行的语言之一,它确实是一种多范式的语言。因此,它可以根据开发者的偏好,采用更多的函数式或OOP方法。

 

三者的对比

面向过程

  • 优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、 Linux/Unix等一般采用面向过程开发,性能是最重要的因素。

  • 不足:不易维护、不易复用、不易扩展

面向对象

  • 优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护

  • 缺点:因为需要创建大量的类,性能不高,不适合对性能要求很苛刻的地方。

函数式编程

  • 优点:变量不可变,引用透明,天生适合并发。表达方式更加符合人类日常生活中的语法,代码可读性更强。实现同样的功能函数式编程所需要的代码比面向对象编程要少很多,代码更加简洁明晰。函数式编程广泛运用于科学研究中,因为在科研中对于代码的工程化要求比较低,写起来更加简单,所以使用函数式编程开发的速度比用面向对象要高很多,如果是对开发速度要求较高但是对运行资源要求较低同时对速度要求较低的场景下使用函数式会更加高效。

  • 缺点:由于所有的数据都是不可变的,所以所有的变量在程序运行期间都是一直存在的,非常占用运行资源。同时由于函数式的先天性设计导致性能一直不够。虽然现代的函数式编程语言使用了很多技巧比如惰性计算等来优化运行速度,但是始终无法与面向对象的程序相比,当然面向对象程序的速度也不够快。函数式编程虽然已经诞生了很多年,但是至今为止在工程上想要大规模使用函数式编程仍然有很多待解决的问题,尤其是对于规模比较大的工程而言。如果对函数式编程的理解不够深刻就会导致跟面相对象一样晦涩难懂的局面。

 

FP 和 OOP 都是前辈们探索出来为更好的维护和协同工作而人为发明的 concept,没有谁好谁坏之分。遇到不同的使用场景,选择最合适的即可。

函数式编程与OOP:关键的区别

函数式编程OOP
一个函数是主要单位 对象是主要单位
纯粹的函数没有副作用 方法可能有副作用
遵循更多的声明式编程模型 主要遵循命令式的编程方式
在纯函数式编程语言中,不可能创建可变的对象。因此,对象通常是不可变的。 在OOP语言中,答案并不那么直接,因为它更多地取决于每种OOP语言的规范。因此,OOP可以同时支持可变和不可变的对象。
函数式编程写的是纯函数。纯函数只产生与输入相同的输出。因此,函数式编程具有极强的操作性、实用性,而且正如其名称所示,是功能性的。 OOP不像函数式编程那样具有操作性。事实上,OOP将数据存储在对象中,数据的优先级高于操作。

如何选择,其是都是又项目架构所决定。

 

 

参考文章:

我对函数式编程、面向对象和面向过程三者的理解 https://blog.csdn.net/jiadajing267/article/details/121216442

面向对象编程 V.S 函数式编程 https://bbs.huaweicloud.com/blogs/303315

每日一题:说说你对函数式编程的理解?优缺点? https://developer.aliyun.com/article/1073601

The do's and don'ts of OOP https://www.imaginarycloud.com/blog/the-dos-and-donts-of-oop/

函数式编程与OOP的内容及主要区别 https://juejin.cn/post/7112646218031267847

 


转载本站文章《再谈编程范式(3):理解面向过程/面向对象/函数式编程的精髓》,
请注明出处:https://www.zhoulujun.cn/html/theory/engineering/model/8932.html

posted @ 2023-03-18 17:59  zhoulujun  阅读(380)  评论(0编辑  收藏  举报