找回密码
 立即注册
首页 业界区 业界 再谈编程范式(3):理解面向过程/面向对象/函数式编程的精 ...

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

匡菲 前天 09:32
面向过程(PO)

面向过程是随着VB一起来到我的世界,那个时候会的非常有限,感觉能把程序写出来自己就非常棒了,VB是做那种可视化界面,在工具栏拖个框框放到面板上,然后就在各个事件上写完整的逻辑,什么封装,抽象,继承一概不懂,就有一种一个方法把实现过程需要的逻辑都罗列了,面向过程分析的是步骤。这样说过于抽象,举个例子,洗衣机洗衣服。
1、打开洗衣机
2、放入衣服
3、放入洗衣液
4、关上洗衣机
拆分流程,完成这件事情,都做了哪些流程,不关心谁做的。这样做行不行,首先肯定没问题,但是有什么问题呢?如果在洗衣服的流程中加个柔顺剂,那么这个洗衣服的流程都存在被改动的风险,即可维护性低,不易扩展,不容易复用。
简单来说面向过程,自顶向下,逐步细化!面向过程,就是按照我们分析好了的步骤,按部就班的依次执行就行了!所以当我们用面向过程的思想去编程或解决问题时,首先一定要把详细的实现过程弄清楚。一旦过程设计清楚,代码的实现简直轻而易举
面向过程是一种最为实际的一种思考方式,就算是面向对象的方法也是含有面向过程的思想,可以说面向过程是一种基础的方法,他考虑的是实际的实现,面向过程是从上往下步步求精。所以面向过程最重要的是模块化的思想方法,面向对象的方法主要是把事务给对象化,对象包括属性和行为,当程序规模不是很大时,面向过程的方法还会体现出一种优势,程序的流程会特别清楚,按着模块与函数的方法可以很好的组织
面向对象(OOP)

面向对象则是随着.Net和Java一起来到我的世界,这个时候已经知道面向过程存在一些问题,也学习过设计模式了,知道程序设计七大原则。
1、单一职责、2、开闭原则、3、里氏替换、4、依赖倒置、5、接口隔离、6、迪米特法则、7、合成复用
也知道面向对象的三大特征,封装,继承,多态。
也知道何为对象?现实世界中,任何一个操作或者是业务逻辑的实现都需要一个实体来完成,也就是说,实体就是动作的支配者,没有实体,就肯定没有动作发生,其实对应到程序世界,实体即对象,对象由属性和方法组成,例如人属性则指身高,体重之类特征性内容,而方法则指能做什么。面向对象把问题看作由对象的属性与对象所进行的行为组成。基于对象的概念,以类作为对象的模板,把类和继承作为构造机制,以对象为中心,来思考并解决问题
有了这些理论该怎么解决面向过程中存在问题呢?接着上边的案例,洗衣机洗衣服,主要涉及两个对象,洗衣机,有两个方法打开洗衣机,关上洗衣机。而人则有三个方法,放衣服,放洗衣液。使用面向对象编程方式
1、洗衣机.打开洗衣机
2、人.放衣服
3、人.放洗衣液
4、洗衣机.关上洗衣机
从编程上区别,就是对象成为了方法的执行者,每个流程的执行都需要一个对象,也就是代码中的类。这样的好处就是,刚才在面向过程中想加入柔顺剂的过程非常简单,在人这个对象中添加个方法即可,就是经常说高耦合低内聚,也变的更加容易维护,拓展,复用也变的容易。
所谓的面向对象,就是在编程的时候尽可能的去模拟真实的现实世界,按照现实世界中的逻辑去处理一个问题,分析问题中参与其中的有哪些实体,这些实体应该有什么属性和方法,我们如何通过调用这些实体的属性和方法去解决问题。
OOP 举例
  1. // 这是初始版本
  2. public class IncomeTaxCalculator{
  3.   protected double _threshold = 3500;
  4.   public double calculate(IncomeRecord record){
  5.     double tax = record.salary <= _threshold ? 0 : (record.salary - _threshold) * 0.2;
  6.     return tax;
  7.   }
  8. }
  9. // 往往 Value Object 一旦发布基本上就很难改变,因为外部已经有很多引用
  10. class IncomeRecord{
  11.     String id; // 身份证号
  12.     String name; // 姓名
  13.     double salary; // 工资
  14. }
  15. // 当需求改变时 OOP 的处理方法
  16. public class IncomeTaxCalculatorV2018 extends IncomeTaxCalculator{
  17.   // 2018年9月1号后起征点调整到了 5000,重写 calculate method 加上这个逻辑
  18.   public double calculate(IncomeRecord record){
  19.     if(today() > date(2018, 9, 1)){
  20.       double _threshold = 5000;
  21.     }
  22.     return super.calculate(record);
  23.   }
  24. }
  25. IncomeTaxCalculator calculator = new IncomeTaxCalculator();
  26. calculator.calculate(new IncomeRecord(1234, 'tiger', 10000));
  27. // 需求改变后,只需要使用新的 class 即可:
  28. IncomeTaxCalculator calculator2018 = new IncomeTaxCalculatorV2018();
  29. calculator2018.calculate(new IncomeRecord(1234, 'tiger', 10000));
复制代码
从以上例子可以看出来原来的 class 完全不需要任何改动,有任何的新需求只需要新增一个 subclass 继承原来的 IncomeTaxCalculator 即可。
不可否认,OOP 对可维护性有非常好的支持,把可维护性带到了一个新的高度。但也有一些弊端。

  • subclass IncomeTaxCalculatorV2018.calculate() 包含了 today(),即 side effect,如果不这么做,那就需要改变 IncomeRecord,即 input
  • parent class 内部变量 _threshold 发生了改变
  • 继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。具体参看《理论七:为何说要多用组合少用继承?如何决定该用组合还是继承? 》
如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承
除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系
对于JavaScript的基础,其是基于原型链继承
1.png

更加复杂一些。
来自游戏公司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这个词在函数式编程中经常出现,可简单理解成匿名函数。
2.png

和面向对象相比,它要规避状态和副作用,即同样输入一定会给出同样输出
虽然函数式编程语言早就出现,但函数式编程概念却是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、函数式编程的显著特征-不可变|无副作用|引用透明

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

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

结构化编程和非结构化编程的区别,从表面上看比较大的一个区别是结构化编程没了“goto”语句。但更深层次是结构化编程使得模块化成为可能。
像goto语句这样的能力存在,虽然会带来一定的便利,但是它会打破模块之间的界限,让模块化变得不容易。
模块化有诸多好处,首先模块内部是更小的单一的逻辑,更容易编程;其次模块化有利于复用;最后模块化使得每个模块也更加易于测试。
模块化是软件成功的关键所在,模块化的本质是对问题进行分解,针对细粒度的子问题编程解决,然后把一个个小的解决方案整合起来,解决完整的问题。这里就需要一个机制,可以将一个个小模块整合起来。函数式编程有利于小模块的整合,有利于模块化编程
3、将函数整合起来 - 高阶函数(Higher-order Functions)

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

  • 接受一个或者多个函数作为其入参(takes one or more functions as arguments)
  • 返回值是一个函数 (returns a function as its result)
假如我们需要计算出学校中所有女生的成绩,和所有女老师的年龄。传统的编程方式我们是这样做的:
  1. //用函数式编程的方式求解,可以这样做:
  2.  
  3. //求所有女生的成绩
  4. List<Integer> grades = students.stream().filter(s -> s.sex.equals("femail")).map(s -> {return s.grade}).collect(Collectors.toList());
  5.  
  6. //求所有女老师的年龄
  7. 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,满足上述定义的都可以成为高阶函数。高阶函数像骨架一样支起程序的整体结构,具体的实现则由作为参数传入的具体函数来实现。因此,我们看到高阶函数提供了一种能力,可以将普通函数(功能模块)整合起来,使得任一普通函数都能被灵活的替换和复用。
4.png

组合与管道

组合函数,目的是将多个函数组合成一个函数
举个简单的例子:
  1. function afn(a){
  2.     return a*2;
  3. }
  4. function bfn(b){
  5.     return b*3;
  6. }
  7. const compose = (a,b)=>c=>a(b(c));
  8. let myfn =  compose(afn,bfn);
  9. console.log( myfn(2));
复制代码
可以看到compose实现一个简单的功能:形成了一个新的函数,而这个函数就是一条从 bfn -> afn 的流水线
下面再来看看如何实现一个多函数组合:
  1. const compose = (...fns)=>val=>fns.reverse().reduce((acc,fn)=>fn(acc),val);
复制代码
compose执行是从右到左的。而管道函数,执行顺序是从左到右执行的
  1. const pipe = (...fns)=>val=>fns.reduce((acc,fn)=>fn(acc),val);
复制代码
组合函数与管道函数的意义在于:可以把很多小函数组合起来完成更复杂的逻辑
柯里化

柯里化是把一个多参数函数转化成一个嵌套的一元函数的过程
一个二元函数如下:
  1. let fn = (x,y)=>x+y;
复制代码
转化成柯里化函数如下:
  1. const curry = function(fn){
  2.     return function curriedFn(...args){
  3.         if(args.length<fn.length){
  4.             return function(){
  5.                 return curriedFn(...args.concat([...arguments]));
  6.             }
  7.         }
  8.         return fn(...args);
  9.     }
  10. }
  11. const fn = (x,y,z,a)=>x+y+z+a;
  12. const myfn = curry(fn);
  13. 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变量就是一个函数,可以作为另一个函数的参数。
  1. var print = function(i){ console.log(i);};
  2. [1,2,3].forEach(print);
复制代码
看待函数式编程,如果只看到一些具体的特性,像map,reduce,缓求值等等,就会觉得不过如此,甚至觉得不过是把一些常用的逻辑整理了一下而已,那就错过了函数式编程的精彩。我们需要从函数式编程的思想基石--基于函数构建软件,以及函数式编程对于模块化的益处,我们就能看到函数式编程思想的魅力。
 
FP 举例

[code]// 初始方法function calculator(record){  const threshold = 3500;  return record.salary 
您需要登录后才可以回帖 登录 | 立即注册