函数式编程实践

回主页

函数成为第一公民

函数式编程作为一种编程范式,是通过组合和调用多个函数来组织代码的。和命令式编程围绕状态进行编程不同的是,函数式力求每个函数都有返回值,尽量减少函数中的副作用。

函数可以作为一个值被一个变量所引用,也可以作为另外一个函数的参数或者返回值。

如下是wiki上的介绍:

In computer science, functional programming is a programming paradigm where programs are constructed by applying and composing functions. It is a declarative programming paradigm in which function definitions are trees of expressions that each return a value, rather than a sequence of imperative statements which change the state of the program.

In functional programming, functions are treated as first-class citizens, meaning that they can be bound to names (including local identifiers), passed as arguments, and returned from other functions, just as any other data type can. This allows programs to be written in a declarative and composable style, where small functions are combined in a modular manner.

详情可参考:https://en.wikipedia.org/wiki/Functional_programming

起源

函数式编程来源自lisp语言,lisp来自于lambda验算,lambda验算是被一个叫邱奇的人提出来的,几乎和图灵机一个时代。如果感兴趣,可以参阅一本书 《AN INTRODUCTION TO FUNCTIONAL PROGRAMMING THROUGH LAMBDA CALCULUS》

特征

为了和OOP有一个清晰的对比,在描述函数式特征之前,我们先描述一需求,然后用OOP的方式去设计它。

需求

根据所载货物和货船的水平面积,计算货船的吃水深度。我们知道,船的水平面积越大,吃水越小,货物越重,吃水越大。

  1. 有一个货船的水平面积是1000平方米,计算它载10吨食品或者20吨玩具的吃水深度
  2. 然后工人们把货船的水平面积增加20平方米,再重新计算载货物的吃水深度
  3. 最后工人们把货船的水平面积改造成原来的20倍

为了简化逻辑,我们可以把吃水深度的计算模型做到最简。weight * 2 / _area,吃水深度与载货的重量成正比,与船体水平面积成反比,比例因子是2。

上代码

code

以下使用dart语言做示例。

OOP式的解法

我们设计Ship类,并且给它设置俩属性,分别是ship的水平面积int _area和它所载的货物Cargo _cargo。再给它添加一个改造船体的方法void refitShip(final int areaDelta, int refitType),以及获取吃水深度的方法double getDraft()。这样我们完成了对货船的类的设计。它将可以载货,也具有船体改造的能力。

class Cargo
{
  String name;
  int weight;

  Cargo({this.name, this.weight});
}

class Ship
{
  int _area;
  Cargo _cargo;
  static const int REFIT_TYPE_ADD = 0;
  static const int REFIT_TYPE_MULTY = 1;

  Ship({int area, Cargo cargo}) : _area = area, _cargo = cargo;

  int get area = > _area;

  set cargo(Cargo cargo) = > _cargo = cargo;

  // 得到吃水深度
  double getDraft()
  {
    return _cargo.weight * 2 / _area;
  }

  // 改装船体水平面积
  void refitShip(final int areaDelta, int refitType)
  {
    switch (refitType)
    {
    case REFIT_TYPE_ADD:
      _area += areaDelta;
      break;
    case REFIT_TYPE_MULTY:
      _area *= areaDelta;
      break;
    }
  }

}

void oop()
{
  final food = Cargo(name : "食品", weight : 10);  // 10吨   
  final toy = Cargo(name: "玩具", weight: 20); // 20吨

  final ship = Ship(area : 1000, cargo : food); // 1000平方米   
  print('${ship.getDraft()}'); // 货船载食品时的吃水深度

  ship.cargo = toy;   
  print('${ship.getDraft()}'); // 货船载玩具时的吃水深度

  ship.refitShip(20, Ship.REFIT_TYPE_ADD); // 货船水平面积增加20平方米
  ship.cargo = food;
  print('${ship.getDraft()}');

  ship.cargo = toy;
  print('${ship.getDraft()}');

  print('${ship.area}');
  ship.refitShip(20, Ship.REFIT_TYPE_MULTY); // 货船水平面积增大20倍
  print('${ship.area}');
}

打印结果如下:

0.02米 ; 这个数据有点夸张,我们知道大概意思就行了
0.04米
0.0196078431372549米
0.0392156862745098米
1020平米
20400平米

用新的方式去实现这个需求

那么我们如何换一种思维去重新做这个需求呢?先看一下函数式编程的一些特征。然后我们采用这些特征去重新做这个需求。

数据是不可变的

对于函数,不管是从参数传进来的还是新初始化的数据,都不再可以改变了。在函数内根据这些数据进行运算,并且把运算结果作为返回值传给被调用者。这更像是数学中的函数概念y=f(x),参数和返回值存在一种映射关系。

举例说明

@dataClass
class Cargo {
  final String name;
  final int weight;
  Cargo({this.name, this.weight});
}

@dataClass
class Ship {
  final int area;
  Ship({this.area});
}

这里货物和货船是dataClass,它们中的属性都是不可变的。那么问题是,我们要增加货船的面积该如何操作?这样做,举例说明。

final refit = (ship, delta) => Ship(area: ship.area + delta); 
refit(ship, 20); // 水平面积增加20平方米 

高阶函数

如果是对船体面积不是进行增加改造,而是倍数改造,那么结合上边的匿名函数,我们可以这样设计。

// extension是什么,后边会介绍到
extension on Ship {
  Ship refitShip(final int areaDelta, Function(Ship, int) apply) {
    return apply(this, areaDelta);
  }
}

final ship = Ship(area: 1000);
// 增加20平方米
final newShip =
    ship.refitShip(20, (ship, delta) => Ship(area: ship.area + delta));
// 增大20倍
final newShipEx =
    newShip.refitShip(20, (ship, delta) => Ship(area: ship.area * delta));

这里把两个匿名函数分别作为参数传递给了refitShip函数,这样实现了对船体面积进行增加或倍数增大,refitShip函数返回新的货船实例用来表示改造后的货船。

当函数可以被赋值(绑定)、或作为别的函数的参数或者返回值,那么它就是高阶函数了。再举例说明

typedef Format = void Function(Object obj);

Format createMyPrintFormat(unit) {
  return (obj) {
    print('$obj$unit');
  };
}

这里我们定义了一个函数Format,格式化打印字符串。我们再看createMyPrintFormat,这个函数的定义是,接受一个参数unit(单位,比如米、平方米等),然后返回可以格式化打印字符串的新函数,这个新函数即是匿名函数也是高阶函数。

那么举例中的extension是什么?

扩展函数

上边实例中extension表示的是扩展函数,在现在高级语言中,大部分支持扩展函数,表示给一个已经被定义过的类,添加新的方法。上边的refitShip即是Ship的扩展函数。再举例说明:

extension on Object {
  toPrint(Format format) => format(this);
}

实例中我们给Object类添加了一个扩展函数toPrint,这样Object将拥有toPrint行为,可以把自己的信息格式化打印成字符串。比如:

final format1 = createMyPrintFormat("");
newShip.area.toPrint(format1); // 1020
newShipEx.area.toPrint(format1); // 20400

科里化可以对计算模型做进一步的抽象

科里化(curry)是可以对N个参数的函数进行分解,分解成N个具有单个参数的函数,更灵活的应用在编程中。转载一下专业的解释:

In mathematics and computer science, currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each take a single argument.> ——来自wikihttps://en.wikipedia.org/wiki/Currying 以kotlin语言举例说明:

// 根据长宽高求体积
fun cube(a: Int, b: Int, c: Int): Int {
  return a * b * c
}

// 科里化
fun curry(a: Int) = fun(b: Int) = fun(c: Int) = cube(a, b, c)

fun main(args: Array<String>) {
  val evalArea = curry(1) //  创建一个求面积的函数
  val cubeOf3X4 = curry(3)(4) // 创建一个求体积的函数。有了长宽,根据高求体积

  println(evalArea(3)(4)) // 12平方
  println(evalArea(2)(3)) // 6平方
  println(cubeOf3X4(2)) // 24立方
  println(cubeOf3X4(3)) // 36立方
}

那么在这个ship需求中如何体现的呢?回到dart语言举例说明:

double computeDraft(final Ship ship, final Cargo cargo) {
  return cargo.weight * 2 / ship.area;
}

double Function(Cargo) shipFunc(final Ship ship) {
  return (Cargo cargo) {
    return computeDraft(ship, cargo);
  };
}

computeDraft接受俩参数货船ship和货物cargo,然后返回吃水深度。这里shipFunc运用了科里化(curry)的方式,接受一个ship作为参数,返回一个新的函数,这个新的函数绑定了刚刚那个ship(ship成为了这个新的函数的上下文),并且这个新的函数将接收货物cargo作为参数,去计算返回吃水深度。

需求的新实现

最终,我们对上边的需求做了重新实现,如下所示:

void functional() {
  final food = Cargo(name: "", weight: 10); // 10吨
  final toy = Cargo(name: "", weight: 20); // 20吨
  final ship = Ship(area: 1000); // 1000平方米水平面积

  final format0 = createMyPrintFormat("");
  final format1 = createMyPrintFormat("");

  final carry = shipFunc(ship);
  carry(food).toPrint(format0); // 货船载食物的吃水面积
  carry(toy).toPrint(format0); // 华航载玩具的吃水面积

  // 对货船进行改造
  final newShip =
      ship.refitShip(20, (ship, delta) => Ship(area: ship.area + delta));
  final carry1 = shipFunc(newShip);
  // 改造后的货船分别载食物和玩具的吃水面积
  carry1(food).toPrint(format0);
  carry1(toy).toPrint(format0);

  final newShipEx =
      newShip.refitShip(20, (ship, delta) => Ship(area: ship.area * delta));
  newShip.area.toPrint(format1);
  newShipEx.area.toPrint(format1);
}

打印结果如下:

0.02米
0.04米
0.0196078431372549米
0.0392156862745098米
1020平米
20400平米

更多特征

那么就结束了吗?不,函数式编程还存在更多特征。

函数中不存在副作用

在类的设计中,要添加一些属性值,或者像C这样没有类的语言中,要添加一些全局变量。我们在使用命令式编程时,在函数内可以对类的属性或者全局变量进行修改,这属于副作用。函数式编程不再围绕这些状态值进行编程,它只接受不可变的参数值,并且返回新的结果。 举例说明 存在副作用的类方法add:

class MyClass(a: Int) {
  var myResult = a
    private set

  fun add(b: Int) {
    myResult += b
  }
}

不存在副作用的类方法add:

class MyClass {     
  fun add(a: Int, b: Int): Int {         
    return a + b
  }
} 

流式的调用

在流的调用、操作符转换、观察者被观察者,很多是把高阶函数作为参数传递进去,像我们看到的map、filter、subscribe等。

程序是线程安全的

由于数据是不可变的,所以不存在多线程排队等待写一块临界区的操作,也就不会存在并发操作和锁的考虑。

带来了什么

命令式编程风格常常迫使我们出于性能考虑,把不同的任务交织起来,以便能够用一次循环来完成多个任务。而函数式编程用map()、filter()这些高阶函数把我们解放出来,让我们站在更高的抽象层次上考虑问题,把问题看得更清楚。 举个栗子,我们从一堆书籍中找到作者是张三的书,同时把他的书的价格打成八折。

以java语言举例,通常写法是:

class Author {
    String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

class Book {
    Author author;
    float price;

    public Author getAuthor() {
        return author;
    }

    public void setAuthor(Author author) {
        this.author = author;
    }

    public float getPrice() {
        return price;
    }

    public void setPrice(float price) {
        this.price = price;
    }
}

public Book method(List<Book> books) {
    Book result = null;
    for (int i = 0; i < books.size(); i++) {
        Book book = books.get(i);
        if (book.getAuthor().getName().equals("张三")) {
            float price = book.getPrice();
            book.setPrice(price * 0.8f);
            result = book;
        }
    }
    return result;
}

我们在读上边的代码时,在读到那个for语句,我们把那for语句8行整体读下来,才知道是要从books列表中查找一本书。

于是函数式的写法,以kotlin举例,可以是

data class Author(val name: String)
data class Book(val author: Author, val price: Float)
fun method(books: List<Book>): Book? {
    val target = books.find { it.author.name == "张三" } ?: return null
    return target.copy(price = target.price * 0.8f)
}

如果想用java来实现这种函数式的写法,可以是:

public Book method1(List<Book> books) {
    // https://www.baeldung.com/find-list-element-java
    Book result = books.stream()
            .filter(it -> "张三".equals(it.getAuthor().getName()))
            .findAny()
            .orElse(null);
    if (result != null) {
        result.setPrice(result.getPrice() * 0.8f);
    }
    return result;
}

也可以利用Google Guava库,这样实现:

public Book method2(List<Book> books) {
    Book result = Iterables.tryFind(books, new Predicate<Book>() {
        @Override
        public boolean apply(Book book) {
            return book.getAuthor().getName().equals("张三");
        }
    }).orNull();
    if (result != null) {
        result.setPrice(result.getPrice() * 0.8f);
    }
    return result;
}

更多特征

记忆、缓求值、尾递归优化(http://www.ruanyifeng.com/blog/2015/04/tail-call.html)、链表操作等。这些就不一一赘述了。

状态机

状态机通常会出现在我们的业务开发中。函数式编程力求无状态编程,遇到状态机的场景时,该怎么办?我们不妨先看一个需求。

需求

制造一款战争机器人,它可以在战场上击杀敌人,但是它有一个弱点,它有一定概率被敌人擒住并改造,然后回到我方阵营。然后它将会变成一个失控的机器人,我方此时再去命令它,它将会发生爆炸,导致我方损失。我们用程序模拟推演一下这个简单事故。这更像是一个状态机的设计。 state_machine

上代码

OOP

class WarRobot {
  static const int NORMAL = 0; // 正常状态
  static const int OUT_OF_CONTROL = 1; // 失控状态
  static const int DIED = 2; // 死亡状态
  int _state = NORMAL;
  int get state => _state;

  // 是否被敌人改造
  bool _isRefitByEnemy() => Random().nextInt(10) > 5;

  // 杀敌
  bool killEnemy() {
    var result = false;
    switch (_state) {
      case NORMAL:
        print('kill one enemy');
        if (_isRefitByEnemy()) {
          // 被敌人改造,进入失控状态
          _state = OUT_OF_CONTROL;
        }
        result = true;
        break;
      case OUT_OF_CONTROL:
        print('bomb! died...');
        // 爆炸,进入死亡状态
        _state = DIED;
        result = false;
        break;
      case DIED:
        print('do nothing');
        result = false;
        break;
    }
    return result;
  }
}

void sampleOfOOPWarRobot() {
  final robot = WarRobot();
  do {
    robot.killEnemy();
  } while (robot.state != WarRobot.DIED);
  robot.killEnemy();
}

打印结果如下:

kill one enemy
kill one enemy
bomb! died...
do nothing

函数式

我们可以把对状态的变换,改造成对函数的变换,在不同时期分别去执行不同函数。在这里,执行完一个函数,并且返回下一个函数并下一次执行。我们可以看到它已经不再像上边那个WarRobot的那个状态机了,它隐去了WarRobot的状态信息。

@dataClass
class Result {
  final bool missionDone; // 是否完成任务
  final Function() nextCommand; // 下一个函数
  Result(this.missionDone, this.nextCommand);
}

bool _isRefitByEnemy() => Random().nextInt(10) > 5;

Result _commandNormalWarRobot() {
  print('kill one enemy');
  if (_isRefitByEnemy()) {
    return Result(true, _commandRefittedWarRobot);
  }
  return Result(true, _commandNormalWarRobot);
}

Result _commandRefittedWarRobot() {
  print('bomb! died...');
  return Result(false, _commandDiedRobot);
}

Result _commandDiedRobot() {
  print('do nothing');
  return Result(false, () => null);
}

void sampleOfFunctionalWarRobot() {
  var result = Result(true, _commandNormalWarRobot);
  do {
    result = result.nextCommand.call();
  } while (result.missionDone);
  result = result.nextCommand.call();
}

打印结果如下:

kill one enemy
kill one enemy
bomb! died...
do nothing

新的思维用在了新的框架上

flutter是否有未来存在很大争议暂且不论,但是接触flutter之后,会对它的UI框架新思维而触动。它的UI也是一棵树,RenderObject树,每个节点提供绘制、布局、响应。但是这个RenderObject树不直接暴露给开发者,开发者需要关注的是Widget树,Widget树本质上是一个配置项,开发者通过编写Widget树来完成对UI的配置,flutter在framework中利用它的Element树,来把配置转为化为RenderObject树。

这个新的UI思维,是和android的java的UI编程区别很大。android中的原生UI是一颗view树,我们可以通过更新某一个view的属性,进而通过invalidate完成UI的刷新。而flutter的这个widget树,我们是通过更替其中某些widget(即替换掉树中的节点,而不是更改节点中的属性),来完成UI的刷新。Widget清晰的标明了不可变性,具体可了解StatelessWidget。函数式编程中的不可变性体现在了StatelessWidget以及它的多种派生类中(像Text、Button等)。流的操作和订阅也可以在Widget树中随处见。

我们来看一个例子,这个例子是提供一个跑秒功能的app。具体代码地址是:https://github.com/sunhang/flutter_timer 效果图:

timer_ui

我们看到,UI中使用了StreamBuilder来订阅数据流,当数据变化时,通过更换widget来进行UI的刷新。而stream来自于我们定义的一层TimerController,里边除了流的管理之外,我们纯粹使用了顶级函数来进行状态切换和事件处理。 我们摘录一部分主要代码进行说明。

UI

UI主要构建部分和流的监听如下所示。

@override
Widget build(BuildContext context) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      StreamBuilder<Event>(
        stream: _timerController.eventStream,
        builder: (context, snapshot) {
          if (!snapshot.hasData) {
            return _DisplayWidget(seconds: 0, status: '');
          }
          final time = snapshot.data.time;
          final status = snapshot.data.ui == UI.STARTED ? '' : '';
          return _DisplayWidget(seconds: time, status: status);
        },
      ),
      _MyTimerOperationWidget(
        timerController: _timerController,
        callback: (command) {
          _timerController.execCommand(command);
        },
      ),
      SizedBox.fromSize(
        size: Size.fromHeight(150),
      ),
    ],
  );
}

Controller 

typedef CommandFunc = Function(Command) Function(Command);
CommandFunc _createStartFunc(
    final Sink<double> timeSink, final Sink<UI> uiSink) {
  CommandFunc func(command) {
    if (command != Command.START) {
      return func;
    }

    var sum = 0.0;
    final timer = Timer.periodic(Duration(milliseconds: 100), (timer) {
      sum += 0.1;
      timeSink.add(sum);
    });
    uiSink.add(UI.STARTED);
    return _createStopFunc(timer, timeSink, uiSink);
  }

  return func;
}

CommandFunc _createStopFunc(
    final Timer timer, final Sink<double> timeSink, final Sink<UI> uiSink) {
  CommandFunc func(command) {
    if (command != Command.STOP) {
      return func;
    }
    timer.cancel();
    uiSink.add(UI.STOPED);
    return _createStartFunc(timeSink, uiSink);
  }

  return func;
}

class TimerController {
  // ...
  // get stream
  // void init()
  // void dispose()
  CommandFunc _nextFunc;
  void execCommand(Command command) {
    _nextFunc = _nextFunc(command);
  }
}

结论

函数式真奇特真新鲜,以后开发中就全力以赴的用了。我想建议的是,“不一定!”。鲁迅说过:“以业务和需求为导向,要具体问题具体分析”。

zaili

在日常开发中,以效率、稳定性、代码可读性为优先。OOP已经流行至少20多年了,证明了它的生命力很强大。它具有继承、封装、多态等特征,这些特征提供了对业务的超强的抽象能力。同时,也在于我们的编程习惯基本从大学时开始形成,而大学很多也是教授命令式编程风格的语言,有时候一个习惯往往会伴随很久。改变有时会带来阵痛!不必为用函数式而用函数式。

于是就有人发问,“那上面讲了那么多例子有什么用”。我个人觉得是,这几年有一个现象,涌现出来的新的编程语言(比如,kotlin、swift、dart、 go),有一个共同特征,它们都拥有对函数式编程的支持。我们需要逐渐开始认识和接触这些新的知识新的编程理念,可能目前我们不会用到这些,但是当我们在看技术社区的帖子时,看官网的教程时,看到这些新的编程特性的应用时也不会感到惊讶了,而且会快速地适应它。

回主页