从函数式编程到声明式UI

《从函数式编程到声明式UI》

一 声明式UI的现状

早期的前端UI一直使用HTML+CSS+JavaScript这一经典的开发范式。HTML和CSS负责页面的布局和样式,JavaScript负责逻辑,命令式地操作Dom完成各种行为。之后不断涌现出的JQuery等各种组件库,也只是围绕Dom的操作进行了一些封装,并没用改变这种命令式操作Dom的模式。

直到React的出现,创造性地提成了声明式UI的概念才彻底颠覆了命令式的开发方式,并引领了整个前端开发风格的转变,甚至影响到其他平台。例如Flutter 以及 Android的Jetpack Compose,IOS的SwiftUI等最新的框架都是基于声明式UI的设计思想。

声明式UI来自函数式编程

HTML、CSS等也是声明式语言,但是那种声明式是不能处理逻辑的声明式,我们这里讲的声明式是可以处理逻辑的声明式,所以必须基于函数式编程实现。声明式UI中到处充满了函数式编程的思想,所以要了理解声明式的优势,必须先了解函数式编程。

二 函数式编程

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

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

纯函数

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

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

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

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

为什么需要复用?
因为在FP中,函数是一等公民,是全局可见的,大家都会用,函数必须具有普适性,所以必须纯洁。

函数式编程(FP)的很多特征与面相对象编程(OOP)是相对立的。OOP的世界中,Class才是一等公民,funciton定义在Class里,而且是为Class实例服务的,操作的都是Class的成员,不同实例的函数执行不会互相干扰,自然也就没有纯洁性要求。

我们可以通过FP和OOP的比较来更好地了解函数式编程的优势和用武之地。

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

虽然FP早于OOP之前诞生,担由于更符合大家对现实世界认知的心智模型,以Java为代表的OOP语言一经诞生便迅速流行开来。但是开发者逐渐发现OOP的缺点也同样突出,便重新转向FP,视图弥补OOP在一些场景下的不足。

1. 面向对象存在的问题

所谓天使的另一半是魔鬼,OOP的缺点正来自其引以为傲的三大特征:继承封装多态
需要公正的说明一点,这三大特征在很多适合OOP的场景中是正向的,问题仅存在与部分场景下,而这些场景可能就是比较适合FP发挥威力的地方。

1.1 继承的问题

无论是否是OOP的信徒,我相信OOP继承的缺陷早已被大家所公认:

a. 菱形继承

首先,因为菱形继承的问题无法解决,所以大部分OOP语言只允许单继承,这就相当于废了继承一半的功力。
《从函数式编程到声明式UI》

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)但依然不能否认其面向对象的特性。封装是指每个实例化的对象都有自己专属的成员属性以及操作这些属性的成员方法。

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

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]

可以看到命令式很具体的告诉计算机如何执行某个任务。而声明式是将程序的描述与求值分离开来。它关注如何用各种表达式来描述程序逻辑,而不一定要指明其控制流或状态关系的变化。为什么我们要去掉代码循环呢?循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。而函数式编程旨在尽可能的提高代码的无状态性和不变性。

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())
    }
}

我们消除了LinearLayoutScaffold、FabScaffold之间的继承关系,后期的扩展也非常灵活。
通过一些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+。
《从函数式编程到声明式UI》
如果是命令式编程,我们要写一个根据数量进行更新的函数:

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本身是不可变的,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

    原文作者:fundroid_方卓
    原文地址: https://blog.csdn.net/vitaviva/article/details/104844393
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞