The state of a declarative UI


The early front-end UI always used the classic combination of HTML+CSS+JavaScript. HTML and CSS were responsible for the layout and style of the page, while JavaScript was responsible for the logic. Various operations for Dom were completed in an imperative way. It’s just wrapped around Dom operations, which are still essentially imperative operations.

It wasn’t until React that the idea of a declarative UI creatively took hold and led to a shift in the style of front-end development, even affecting other platforms. Various new technologies such as Flutter, Jetpack Compose, SwiftUI, etc. are all based on the idea of a declarative UI.

Declarative UIs come from functional programming

HTML, CSS, and so on are declarative languages, but those declarative languages are those that can’t handle logic. The declarative ones we’re talking about here are those that can handle logic, so they have to be implemented based on functional programming.

Declarative UIs are full of ideas about functional programming, so to understand the benefits of declarative programming, you must first understand functional programming.


Two functional programming


Functional programming (abbreviated FP) is the process of building software by combining pure functions to avoid sharing state, variable data, and side effects.

Extract the key words from the above definitions: combination, pure function, and no side effects, which will be covered later.

Pure functions

The most important concept of FP is the pure function, which satisfies the following three characteristics:

  • No side effect
  • Reference transparent
  • invariance

Both referential transparency and immutability are complementary to the first feature. Referential transparency means that the calculation process is independent of the outside world; Immutability refers to the calculation process does not affect the outside world, all the objectives are around no side effects, so our determination boundary for FP is the function side effects: no side effects function can meet the requirements of FP.

Why no side effects? Because we want the execution of functions to be predictable and reusable.

So why reuse? Because in FP, functions are first-class citizens, globally visible, and can easily be affected globally if there are side effects during execution, it is important to maintain “purity”.

Many of the features of functional programming (FP) are opposed to OOP: in OOP’s world, classes are first-class citizens, funciton is defined within a Class, and only Class instances are responsible for the execution of functions in different instances, so there is no requirement for “purity”.

Compare FP with OOP to better understand the advantages of functional programming, which is the applicable scenario.


Three-functional programming vs. object-oriented programming


Although OOP appeared later than FP, it is more in line with the mental model of people’s cognition of the real world. As soon as THE OOP language represented by Java was born, it quickly became popular. As developers began to realize that OOP’s shortcomings were equally prominent, they began to look to FP for help.

1. Problems existing in object orientation

The other half of the angel is the devil, and OOP’s shortcomings come from its three prides: inheritance, encapsulation, and polymorphism.

It is fair to say that these three characteristics are positive in many scenarios where OOP is appropriate, and that the problem exists only in some scenarios where FP is likely to be most effective.

1.1 Inheritance issues

Whether you’re a believer in OOP or not, the pitfalls of OOP inheritance are well known:

A. Diamond inheritance

First, because the diamond inheritance problem is unsolvable, most OOP languages allow only single inheritance, which eliminates half the power of inheritance.

class PoweredDevice {}class Scanner extends PoweredDevice {
  void start(a) {... }}class Printer extends PoweredDevice {
  void start(a) {... }}class Copier extends Scanner.Printer {}Copy the code

The Jvm has no way of knowing whether start() came from mom or dad

B. Fragile base class problems

Second, inheritance logic is extremely fragile, and any change to the base class will affect the subclasses. OOP itself provides a solution by using composition instead of inheritance:

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; }}Copy the code

Changes to the base class may affect the logic of subclasses as follows:

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

Subclasses no longer work properly.


1.2 Encapsulation Problems

Many people think that encapsulation refers to the visibility of members. In fact, visibility is only one of the means to achieve encapsulation. Many OOP languages have low requirements for visibility (for example, Kotlin already uses public by default, and JS even has only public), but they still cannot deny the object-oriented features. Encapsulation means that each instantiated object has its own member properties and member methods that manipulate those properties.

Encapsulation allows an object to define its own state and behavior, which is the basis for an object to exist independently, so it seems to me that encapsulation is even more important to OOP than inheritance. So what’s wrong with encapsulation?

A. Encapsulation of states

By wrapping an object’s internal members, you can hide them. This encourages people to make objects more private. Private state creates two problems:

  • People tend to think that state is private and can be changed at will, but because languages like Java are passed by reference, even changes to private members can affect external objects, making the state of the program unpredictable.
  • Private state synchronization between multiple instances is costly, and global variables are an effective way to solve state consistency, but using global variables is regarded as a shameful behavior in the OOP world.

B. Encapsulation of behavior

The state of an object may change, and encapsulation can hide the implementation of the change. So the essence of encapsulation is change. Without change, there is no need for encapsulation.

A program is A bunch of objects telling each other what to do by sending messages – “Thinking in Java”

Encapsulation means that there is change. Change is the root cause of complexity. The main process of programming is to control object change imperatively, but the logical complexity of this imperative increases exponentially as the business needs swell.

1.3. Polymorphism problem

Polymorphism is actually dependent on inheritance, and it is illogical to keep it abreast of encapsulation and inheritance. It may be that the designers of OOP thought polymorphism was so wonderful that they elevated it to the top three. The idea of polymorphism itself is fine, but we must admit that sometimes it is impossible to ignore subclass methods in the base class, so instanceOf comes along, breaking the open closed principle and polluting the logic of the parent class.


2. Benefits of functional programming

Functions are the basic units of computer Programming languages. It is important to note that Functional Programming is quite different from function-used Programming. Like object-oriented programming, functional programming is a programming paradigm.

As we know from the introduction above, the three advantages that OOP is proud of can be the root cause of the problem in many cases, so we tried to find a solution in a completely different programming paradigm, FP

2.1 Combination VS inheritance

Composition is the only way to extend THE functionality of FP, and we implement complex logic through the combination of multiple functions. Combinations are also reusable, because the functions in FP are first-class citizens and are more adaptable.

Even OOP design patterns are increasingly being promoted:

favor composition over inheritence

2.2 Reference transparency VS encapsulation

Object-oriented programming makes code understandable by encapsulating moving parts. Functional programming makes code Fateful by minimizing moving parts. — Michael Feathers, author of Working with Legacy Code, Via Twitter Object-oriented programming constructs readable code by encapsulating the mutable parts. Functional programming constructs readable code by minimizing the mutable parts — Michael Feathers

A pure function should have no internal state, all inputs involved in the calculation are only parameters, and all changes are predictable. This is called reference transparency. In addition, in order to minimize side effects, the variables in FP are not allowed, unlike OOP, which can cause internal escapes due to improper use of referent-type variables. So many FP languages don’t like to use reference variables.

2.3 Higher order functions VS polymorphisms

In functional programming, polymorphism in OOP is replaced by higher-order functions. The same basic function combined with different functions produces different behaviors. The higher-order function itself is also a concrete manifestation of combination.

2.4 Declarative VS imperative

Back to the topic at the beginning of this article: declarative and imperative. As we discussed earlier, encapsulation leads to imperative logic control, as opposed to FP’s declarative logic control

  • 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.

Imperative tells the computer what to do, declarative tells the computer what to do:

/ / imperative
var a = [1.2.3];
var b = [];
for (i=0; i<3; i++) { b.push(a[i] * a[i]);// The essence of encapsulation: change B through B's push method
}
console.log(b); / /,4,9 [1]

/ / the declarative
var a = [1.2.3];
var b = a.map(function(i){
    return i*i
});
console.log(b); / /,4,9 [1]
Copy the code

As you can see, imperative tells the computer specifically how to perform a task, whereas declarative separates the description of the program from its evaluation. It is concerned with how program logic can be described in various expressions without necessarily indicating changes in its control flow or state relationships.

Commands and control statements, such as for loops, are hard to reuse and hard to insert into other operations. Maps are much more reusable. Functional programming is conducive to improving the statelessness and invariance of code, which is conducive to reuse.

OOP implements logic only by sending commands to objects, while declarative implements logic by combining various functions and referring to transparent pure functions. There are more and more declarative libraries in OOP languages, such as RxJava and streamApi introduced by Java8. They are built on a functional basis, so

Functions are the basis of declarations; Declarative is a functional practice


Apply functional programming to UI development


From the previous section we learned about OOP’s inherent shortcomings and FP’s strengths. When we develop client UIs based on OOP, we also encounter these problems, which can also be solved using FP:

1. Replace inheritance with composition

We often implement custom views in Android and use them in XML. For example, we define a Scaffold that we want to use as a generic container class on similar pages.

// The following is the pseudo code for the class Kotlin. Don't worry about the details
class Scaffold extends LinearLayout {
    private val appBar = AppBar()
    private val body = FrameLayout()

    init {
        addView(appBar)
        addView(body)
    }

    showContent(view: View) {
        body.addView(view)
    }
}
Copy the code

We want to add a FloatActionButton on top of the Scaffold. Based on the OOP design pattern, we prefer to implement a composite:

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

However, the Scaffold methods, such as showContent, cannot be inherited and we do not want to override those methods

class FabScaffold extends Scaffold {
    init {
        addView(FloatActionButton()) //Scaffold is inherited from LinearLayout and does not display as expected}}Copy the code

Because the Scaffold inherits from the LinearLayout and does not display as expected, we want the Scaffold to inherit from FameLayout. However, changing the base class can affect other subclasses.

That Scaffold is inherited from FrameLayout without any risk of failure. That Scaffold is inherited from the Scaffold. After a while, the BottomScaffold and FabBottomScaffold are required to be added successively, which causes the classic diamond inheritance problem. I’m too south.

The above case, while extreme, highlights the lack of flexibility of inheritance-based UIs.

What if you use the FP idea of composition instead of inheritance?

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())
    }
}
Copy the code

We eliminated the inheritance between LinearLayout, Scaffold, and FabScaffold, and the later extensions were very flexible.

With a few DSLS or syntactic sugar, we can make the code look cleaner

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

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

Defining a View in a declarative way is what’s called a declarative UI.

Now, you might say, what if I want to change the view parameter? If I had written the previous imperative, I could have done this:

scaffold.removeAllViews()
scaffold.addChild(view)
Copy the code

But in a declarative UI, I can only refresh the UI by calling the function again and passing in a new view, which will cause the parent view like the LinearLayout to be repeatedly created. So in order to keep FP mode working properly, we needed to create a cheaper VirtualDom instead of Expensive View, which is what most declarative UI frameworks do:

cheap obj expensive obj
react component Dom/Bom
flutter element native view

At Facebook, we use React for hundreds of components. We did not find a case where inheritance was needed to build the component hierarchy. –react

Even though there are declarative frameworks that don’t use VirtualDom (Jetpack Compose, for example, uses a Gap Buffer), they all do the same thing: replace inheritance with composition, and execute the combined functions as pure functions.

2. Replace imperative with declarative

As mentioned above, we made the controls declaratively defined by composition. In addition to making the definition of the control simpler, declarative can also simplify our various logical processing.

When we customize a control in OOP mode, we must control its logic in an imperative manner based on encapsulation requirements: For example, we need to display an envelope icon on the dock bar. When the number of unread messages is 0, an empty envelope icon will be displayed. When there are several messages, a letter icon and message number badge will be added to the envelope icon.

If we were doing imperative programming, we would write a function that updates by quantity:

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")}}Copy the code

We rely on a variety of conditional statements to control the UI to render it in the correct state, and this logic can become more complex in a real project. To make this logic more maintainable, we need to explore how to better decouple the UI from the logic, thus resulting in complex design patterns.

It is much easier to write this logic declaratively, even as the control is defined, without the need for decoupling

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

Imperative requires that we implement the logic of the change, whereas declarative focuses only on the current state and not on the change. This is done through VirtualDom’s Diff.

3. Replace encapsulation with reference transparency

In the previous example, updateCount encapsulates the logic for changing the state (count is modified internally as a member variable). If you do this more often than not, changes to the shared state will come from all over the place and the dependence on the shared state will become untrustworthy;

BadgeEnvelope is a pure function whose computation does not involve changes to or depend on any member variables. The logic of the UI display depends only on the single parameter count, which is called reference transparency. The construction of the entire UI follows the model of a pure function:

In addition, to minimize side effects, we want the UI itself to be immutable, with UI changes occurring only in f(), in line with FP’s requirement for immutability. So you’ll find that in a declarative framework like this, the UI components are immutable.

4. Replace polymorphism with higher-order functions

The purpose of polymorphism is to retain its own unique behavior (subclass logic) based on the reuse of common logic (parent logic); In declarative UIs, common logic can be reused and unique logic can be replaced by higher-order functions:

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")}}}Copy the code

As above, we can reuse the Envelope logic and replace the Badge component at the same time by adding a parameter of type () -> Unit.


Five summarizes


The author is a client development, this article uses a lot of Android code as an example, in fact in the front end of similar declarative UI examples will be more. But regardless of the code, the idea is the same:

Declarative UI is the best practice of FP in UI development when we try to adapt the existing OOP development approach with FP thinking.

Declarative UIs aren’t perfect, of course, and they have inherent flaws:

  • The lack of object-based state encapsulation, the need to rely on global variables to manage shared state, state can not be effectively divided and conquer
  • UI refresh is driven by state, and even small changes cannot be modified in an imperative way. Therefore, even with the assistance of VirtualDom, the page refresh performance is not as high as the imperative way

These disadvantages may be the advantages of OOP over FP. No pattern is a silver bullet, and we need to choose the most appropriate one for the scenario. For at least some client-side SPA (single-page Application) development, FP and declarative UIs are a good choice.


reference


Goodbye, Object Oriented Programming

Declarative vs Imperative Programming

Coupling and composition, Part 1

KotlinConf 2019: The Compose Runtime