从函数式编程到声明式UI

2,501 阅读13分钟

一 声明式UI的现状


早期的前端UI一直使用HTML+CSS+JavaScript这一经典组合,HTML和CSS负责页面的布局和样式,JavaScript负责逻辑,通过命令式的方式完成针对Dom的各种操作,之后涌现的JQuery等各种组件库,也只是围绕Dom的操作进行了封装,本质上仍然是命令式的操作方式。

直到React的出现,创造性地提成了声明式UI的概念并引领了整个前端开发风格的转变,甚至影响到其他平台。例如FlutterJetpack ComposeSwiftUI等各种新技术都是基于声明式UI的设计思想。

声明式UI来自函数式编程

HTML、CSS等也是声明式语言,但是那种声明式是不能处理逻辑的声明式,我们这里讲的声明式是可以处理逻辑的声明式,所以必须基于函数式编程实现。

声明式UI中到处充满了函数式编程的思想,所以要了理解声明式的优势,必须先了解函数式编程。


二 函数式编程


函数式编程(缩写为 FP)是一种通过组合纯函数来构建软件的过程,避免状态共享、可变数据及副作用的产生。

从以上定义中提取关键词:组合纯函数无副作用,这些都会在后文中介绍到。

纯函数

FP最重要的概念是纯函数,纯函数满足以下三大特征:

  • 无副作用
  • 引用透明
  • 不变性

引用透明和不可变性都是对第一个特征的补充,引用透明指计算过程不依赖外界;不可变性指计算过程不影响外界,所有目标都是围绕无副作用来的,因此我们对于FP的判定边界就在于函数的副作用:没有副作用的函数才能够符合FP的要求

为什么要求无副作用?因为我们希望函数的执行是可预期、可复用的。

那为什么需要复用?因为在FP中,函数是一等公民,全局可见,如果执行中有副作用很容易影响全局,所以要保持“纯洁性”。

函数式编程(FP)的很多特征与面相对象编程(OOP)是相对立的:OOP的世界中,Class是一等公民,funciton定义在Class里,只对Class实例负责,不同实例的function执行互不干扰,自然也就没有对“纯洁性”的要求。

通过FP和OOP的比较可以更好地了解函数式编程的优势即适用场景。


三 函数式编程 VS 面向对象编程


虽然OOP较于FP出现的更晚,但是更符合大家对现实世界认知的mental model,以Java为代表的OOP语言一经诞生便迅速流行开来。当开发者们逐渐发现OOP的缺点也同样突出时,又开始视图通过FP来寻求帮助。

1. 面向对象存在的问题

所谓天使的另一半是魔鬼,OOP的缺点正来自其引以为傲的三大特征:继承封装多态

需要公正的说明一点,这三大特征在很多适合OOP的场景中是正向的,问题仅存在与部分场景下,而这些场景可能就是比较适合FP发挥威力的地方。

1.1 继承的问题

无论是否是OOP的信徒,OOP继承的缺陷早已被大家熟知:

a. 菱形继承

首先,因为菱形继承的问题无法解决,所以大部分OOP语言只允许单继承,这就相当于废了继承一半的功力。

在这里插入图片描述

class PoweredDevice { 
}

class Scanner extends PoweredDevice {
  void start() { ... }
}

class Printer extends PoweredDevice {
  void start() { ... }
}

class Copier extends Scanner, Printer {
}

Jvm无法知道start()来自爸爸还是妈妈

b. 脆弱的基类问题

其次,继承的逻辑极其脆弱,基类的任何改动都会影响子类。OOP自己也提供了解决方案,即使用组合替代继承:

import java.util.ArrayList;
 
public class Array
{
  private ArrayList<Object> a = new ArrayList<Object>();
 
  public void add(Object element)
  {
    a.add(element);
  }
 
  public void addAll(Object elements[])
  {
    for (int i = 0; i < elements.length; ++i)
      a.add(elements[i]); // this line is going to be changed
  }
}

public class ArrayCount extends Array
{
  private int count = 0;
 
  @Override
  public void add(Object element)
  {
    super.add(element);
    ++count;
  }
 
  @Override
  public void addAll(Object elements[])
  {
    super.addAll(elements);
    count += elements.length;
  }
}

基类的修改有可能会影响子类的逻辑,如下:

public void addAll(Object elements[])
{
    for (int i = 0; i < elements.length; ++i)
      add(elements[i]); // this line was changed
}

子类已经无法正常work了。


1.2 封装的问题

很多人认为封装就是指成员的可见性,其实可见性只是实现封装的手段之一,很多OOP语言对可见性的要求很低(例如 Kotlin已经默认使用public了,JS甚至只有public)但依然不能否认其面向对象的特性。封装是指每个实例化的对象都有自己成员属性以及操作这些属性的成员方法。

封装使对象可以定义自己的状态和行为,这是一个对象可以独立存在的基础,所以在我看来封装之于OOP的意义甚至高于继承。那么封装有什么问题吗?

a. 对状态的封装

通过封装可以隐藏对象的内部成员,这变相鼓励人们让对象拥有更多的私有状态,私有状态会带来两个问题:

  • 大家往往认为状态是私有的便可以随意修改,但因为java等语言都是引用传递,所以即使是对私有成员的修改也可能会影响到外部对象,整个程序状态难以预期。
  • 多实例之间的私有状态同步成本高,全局变量是一个解决状态一致性的有效手段,但是在OOP世界的认知里使用全局变量是令人不齿的行为。

b. 对行为的封装

一个对象的状态可能会发生变化,通过封装可以隐藏变化的具体实现。所以封装的本质即变化,没有变化就无需封装。

A program is a bunch of objects telling each other what to do by sending messages
-《Thinking in java》

封装意味着存在变化,变化是让程序变复杂的根本原因,编程的主要过程就是命令式地控制对象变化,但是这种命令式的逻辑复杂度随着业务需求的膨胀指数级增长。

1.3. 多态的问题

多态实际上是依附于继承的,让它与封装、继承并驾齐驱是不符合逻辑的。可能是OOP的设计者觉得多态太美妙了,就把它上升到三大将的位置。多态本身的思想没有问题,但我们必须承认,有时候在基类中是无法忽视子类方法的,所以就有了instanceOf的出现,破坏了开闭原则,父类的逻辑遭到污染。


2. 函数式编程的好处

函数是计算机程序语言中的基本单元,需要注意函数式编程(Functional Programming)与函数编程(function-used Programming)完全不同。函数式编程跟面向对象编程一样,是一种编程范式。

通过上面的介绍我们知道OOP引以自豪的三大优势在很多时候会成为问题的根源,所以我们试图从FP这样一种完全不同的编程范式中找到解决办法

2.1 组合 VS 继承

组合是FP功能扩展的唯一手段,我们通过多个函数的组合执行来实现各种复杂逻辑。组合同样具有复用性,因为FP中的函数是一等公民,其适应范围更广泛。

就连OOP设计模式也不断推崇:

favor composition over inheritence

2.2 引用透明 VS 封装

Object-oriented programming makes code understandable by encapsulating moving parts. Functional programming makes code understandable by minimizing moving parts.
— Michael Feathers, author of Working with Legacy Code, via Twitter

面向对象的编程通过封装可变动的部分来构造出可让人读懂的代码 函数式编程则是通过最小化可变动的部分来构造出可让人读懂的代码
——Michael Feathers

最小化变动部分就是指副作用最小化,一个纯函数不应藏有内部状态,所有参与计算的输入只有参数,所有的变化都是可预期的,这被称为引用透明。另外,为了最小化副作用,FP中的变量都是不可的,不会像OOP那样出现因引用型变量使用不当造成内部逃逸的问题。所以很多FP语言中不太喜欢使用引用型变量。

2.3 高阶函数 VS 多态

函数式编程中通过高阶函数替代OOP中的多态,同一个基础函数与其它不同函数组合,会产生不同的行为。 高阶函数本身也是组合的一种具体体现。

2.4 声明式 VS 命令式

回到文章开头的话题:声明式与命令式。我们在前文讨论过,封装带来了命令式的逻辑控制,与之相对的FP采用声明式的逻辑控制

  • Declarative programming is a programming paradigm … that expresses the logic of a computation without describing its control flow.

  • Imperative programming is a programming paradigm that uses statements that change a program’s state.

命令式告诉计算机如何做,声明式告诉计算机做什么:

//命令式
var a = [1,2,3];
var b = [];
for (i=0; i<3 ;i++) {
    b.push(a[i] * a[i]); //封装的本质:通过b的push方法,使b发生变化
}
console.log(b); // [1,4,9]

//声明式
var a = [1,2,3];
var b = a.map(function(i){
    return i*i
});
console.log(b); // [1,4,9]

可以看到命令式很具体的告诉计算机如何执行某个任务,而声明式是将程序的描述与求值分离开来。它关注如何用各种表达式来描述程序逻辑,而不一定要指明其控制流或状态关系的变化。

for循环这样的命令控制语句,难以复用,也难以插入其他操作,而map的复用性大大提升。函数式编程有利于提高代码的无状态性和不变性,利于复用。

OOP只能通过向对象发送命令实现逻辑,而声明式通过各种函数的组合以及引用透明的纯函数实现逻辑,OOP语言中开始出现越来越多的声明式库,例如RxJava以及Java8引入的streamApi等,他们都是建立函数式基础上的应用,所以

函数式是声明式的基础;声明式是函数式的实践


四 将函数式编程应用到UI开发


通过前文我们了解到OOP的先天不足和FP的优势。当我们基于OOP开发客户端UI时,同样也会碰到这些问题,也同样可以使用FP进行化解:

1. 用组合替代继承

我们在Android中经常会实现自定义View,并在xml中使用。例如我们定义了一个Scaffold,希望为一个通用的容器类在类似页面中使用。

//以下是类kotlin伪代码,不要在意细节,只为说明问题
class Scaffold extends LinearLayout {
    private val appBar = AppBar()
    private val body = FrameLayout()

    init {
        addView(appBar)
        addView(body)
    }

    showContent(view: View) {
        body.addView(view)
    }
}

产品希望实在原有Scaffold的基础上增加一个FloatActionButton,根据OOP设计模式的推荐,我们优先考虑用组合的方式实现:

class FabScaffold extends FrameLayout {
    init {
        addView(Scaffold())
        addView(FloatActionButton())
    }
}

但是这样做,无法继承Scaffold的方法,例如showContent等,而我们又不想重写这些方法,想想还是改成继承吧

class FabScaffold extends Scaffold {
    init {
        addView(FloatActionButton()) //Scaffold继承自LinearLayout,不符合期望的显示效果
    }
}

因为Scaffold继承自LinearLayout,不符合期望的显示效果,这时我们希望让Scaffold继承自FameLayout,但是擅自改动基类又有可能对其他子类造成影响。

好不容易在确保万无一失的情况下改为Scaffold继承自FrameLayout,为FabScaffoldScaffold建立了继承关系。过了一段时间,产品有要求陆续增加BottomScaffoldFabBottomScaffold,此时又产生了经典的菱形继承问题。。我太南了。。

上面的case虽然比较极端,但是充分暴露了基于继承的UI缺少灵活性的问题。

如果改用FP的思想用组合代替继承,是怎样的呢?

fun Scaffold(view : View) {
    return LinearLayout().apply{
        addView(Appbar())
        addView(Framelayout().apply{
            addView(view)
        })
    }
}

fun fabScaffold(view: View) {
    return Framelayout().apply{
        addView(Scaffold(view))
        addView(FloatActionButton())
    }
}

我们消除了LinearLayoutScaffoldFabScaffold之间的继承关系,后期的扩展也非常灵活。

通过一些DSL或者语法糖,我们可以然代码看起来更简洁

fun scaffold(view: View) =
    LinearLayout {
        Appbar()
        addView(view)
    }

fun fabScaffold(view: View) =
    FrameLayout {
        Scaffold(view)
        FloatActionButton()
    }

用声明式的方式定义View,就是所谓的声明式UI。

也许有人会质疑,如果想更改参数view怎么办呢?如果是以前命令式的写法, 我可以这样做:

scaffold.removeAllViews()
scaffold.addChild(view)

但是在声明式UI中,我只能通过再次调用函数并传入新的view来刷新UI,这将造成LinearLayout等父View的重复创建。所以为了保证FP模式的正常运转,我们需要通过创建更cheap的VirtualDom来代替expensive的View,这也是大多数类似声明式UI框架的做法:

cheap objexpensive obj
reactcomponentDom/Bom
flutterelementnative view

在 Facebook,我们在成百上千个组件中使用 React。我们并没有发现需要使用继承来构建组件层次的情况。
--react

即使有一些声明式框架没有使用VirtualDom机制(比如Jetpack Compose使用的是Gap Buffer)但他们实现目的都是一样的:用组合替代继承,并可以以纯函数的方式执行组合后的函数。

2. 用声明式替代命令式

如上所述,我们通过组合让控件实现了声明式定义。声明式除了让控件的定义更简单,还可以简化我们的各种逻辑处理。

当我们用OOP方式自定义控件,基于封装性的要求,必须通过命令式的方式控制其逻辑: 比如我们需要在dock栏上显示一个信封图标,未读消息数是 0 的时候显示一个空信封的图标,有几个消息的时候在信封图标上加个信件图标和消息数 badge,消息数超过 100 时再加个火苗并且 badge 不再是具体数字而是 99+。

如果是命令式编程,我们要写一个根据数量进行更新的函数:

fun updateCount(count: Int) {
    if (count > 0 && !hasBadge()) {
        addBadge()
    } else if (count == 0 && hasBadge()) {
        removeBadge()
    }
    if (count > 99 && !hasFire()) {
        addFire()
        setBadgeText("99+")
    } else if (count <= 99 && hasFire()) {
        removeFire()
    }
    if (count > 0 && !hasPaper()) {
        addPaper()
    } else if (count == 0 && hasPaper()) {
        removePaper()
    }
    if (count <= 99) {
        setBadgeText("$count")
    }
}

我们依靠各种条件语句控制UI以使其呈现正确的状态,实际项目中这个逻辑可能会变得更加复杂。而为了使这段逻辑更易维护,我们还要探究如何将UI与逻辑更好地解耦,因而延生出各种繁杂的设计模式。

如果使用声明式的方式写这段逻辑则简单得多,甚至在控件定义的同时就完成了逻辑的实现,无需考虑解耦的问题

fun BadgeEnvelope(count: Int) {
    Envelope(fire = count > 99, paper = count > 0) {
        if (count > 0) {
            Badge(text = if (count > 99) "99+" else "$count")
        }
    }
}

命令式需要我们实现变化的逻辑, 而声明式只关注当前状态不关注变化。具体的变化过程是通过VirtualDom的diff帮我们完成的。

3. 用引用透明替代封装

前面例子中,updateCount中封装了变化状态的逻辑(count作为一个成员变量在内部被修改),如果这种方法多了之后,对共享状态的修改会来自四面八方,对于共享状态的依赖也变得不可信赖;

BadgeEnvelope是一个纯函数,其计算过程不涉及成员变量的修改也不依赖任何成员变量,UI显示的逻辑只依赖唯一参数count,也就是所谓的引用透明,整个UI的构造符合一个纯函数的模型:

此外,为了最小化副作用,我们希望UI本身是不可变的,UI的变化只发生在f()中,契合了FP对不变性的要求。所以你会发现类似的声明式框架中,其UI组件都是不可变的。

4. 用高阶函数替代多态

多态的目的是复用共同逻辑(父类逻辑)的基础之上,保留自己独有的行为(子类逻辑);声明式UI中可以通过高阶函数实现共有逻辑的复用和独有逻辑的可替换:

fun BadgeEnvelope(badge: (Int) -> Unit, count: Int) {
    Envelope(fire = count > 99, paper = count > 0) {
        if (count > 0) {
            badge(text = if (count > 99) "99+" else "$count")
        }
    }
}

如上,我们可以通过增加() -> Unit类型的参数,复用Envelope逻辑同时替换Badge组件。


五 总结


笔者是一个客户端开发,文中使用了大量的Android代码做例子,实际在前端中类似的声明式UI的例子会更多。但无论何种代码,思想是相通的:

当我们在UI开发中试着用FP的思想改造既有的OOP开发方式时便得到了声明式UI,声明式UI是FP在UI开发中的最佳实践。

当然声明式UI并非完美,也有固有缺陷:

  • 缺少基于对象的状态封装,需要依靠全局变量管理共享状态,状态无法有效分治
  • UI的刷新依靠状态驱动,即使再小的变更也不能命令式地修改,所以即使有VirtualDom的辅助,其页面刷新性能也不及命令式方式的高

这些缺点可能也就是OOP相对FP的优点所在,任何模式都不是银弹,我们需要根据场景选择最合适的,至少在一些客户端的SPA(Single-Page-Application)开发中,FP和声明式UI是个不错的选择。


参考


Goodbye, Object Oriented Programming

Declarative vs Imperative Programming

Coupling and composition, Part 1

KotlinConf 2019: The Compose Runtime