preface

Lens is an abstract concept that, as its name implies, allows you to peer deep into a data structure to observe and modify the data inside the structure. Like lenses in the real world, Lens can be combined to form groups of lenses that can manipulate deeper levels of data.

This article will explain Lens and how to use it, covering many of the concepts of functional programming. Here’s a metaphor to get you started on Lens: Think of Lens as a Getter and Setter for immutable data structures.

One thing to note here is that Lens has a highly abstract implementation in some functional programming languages, such as Haskell, with getters and setters. The program description language used in this paper is Swift, but due to the Type system of Swift language is not perfect enough, some Type features in functional programming cannot be realized temporarily (some higher-order Type classes, such as Functor and Monad), which cannot be like Haskell and other languages. Make Lens both Getter and Setter capabilities. Considering that Swift, as an object-oriented programming paradigm-compliant language, allows access to internal members of immutable data structures through point syntax, this article will only implement and explain the Setter feature of Lens.

In Haskell and other languages, Lens is implemented as a Functor (Functor). Its purpose is to improve abstraction by providing both setters and getters to Lens: Identity Functor implements the Setter function and Const Functor implements the Getter function. An article describing Lens principles using Haskell may be published later, so stay tuned.

Lens Swift implementation source code has been uploaded to the lot, interested friends can click to view: TangentW/Lens | Lens for Swift, welcome the Issue or PR.

You probably don’t use immutable data much in your daily development, but The Lens concept may give you a new perspective on functional programming.

Immutable data

In order to ensure the stable running of the program, developers often need to spend a lot of energy to carefully control various variable program states, especially in the context of multi-threaded development. The invariance of data is one of the characteristics of functional programming. This constraint on data ensures the existence of pure functions and reduces the uncertainties in program code, thus making it easier for developers to write robust programs.

Swift for immutable data to establish a set of perfect wit, we use the let declaration and definition of constant itself has the immutability (but here need to distinguish between Swift value types and reference types, reference types due to pass is quoted, like a pointer, so a reference type constant does not guarantee its point to objects do not change).

struct Point {
    let x: CGFloat
    let y: CGFloat
}

let mPoint = Point(x: 2, y: 3)
mPoint.x = 5 // Error!
Copy the code

“Changes” to immutable data

A lot of times, change does require, it’s not possible for all states to be static while the program is running. In fact, “change” for immutable data is actually to build a new data based on the original data, all of these “changes” do not occur in the original data:

// Old
let aPoint = Point(x: 2, y: 3)
// New
let bPoint = Point(x: aPoint.x, y: aPoint.y + 2)
Copy the code

Many apis in the Swift STL, for example, use this idea, such as the Map and filter methods in the Sequence protocol:

let inc = { $0 + 1 }
[1.2.3].map(inc) / / [2, 3, 4]

let predicate = { $0 > 2 }
[2.3.4].filter(predicate) / / [3, 4]
Copy the code

This method of “changing” the data is also fundamentally unchanged, ensuring the immutability of the data.

The introduction of Lens

“Changing” an immutable data, creating new data from the original data, is as simple as the example shown above:

let bPoint = Point(x: aPoint.x, y: aPoint.y + 2)
Copy the code

But this approach to “changing” immutable data can be disastrous if the data hierarchy becomes more complex:

// Represents the structure of the line segment
struct Line {
    let start: Point
    let end: Point
}

/ / line A
let aLine = Line(
    start: Point(x: 2, y: 3),
    end: Point(x: 5, y: 7))// Move the starting point of line segment A up 2 coordinates to get A new line segment B
let bLine = Line(
    start: Point(x: aLine.start.x, y: aLine.start.y),
    end: Point(x: aLine.end.x, y: aLine.end.y - 2))// Move segment B 3 to the right to create a new segment C
let cLine = Line(
    start: Point(x: bLine.start.x + 3, y: bLine.start.y),
    end: Point(x: bLine.end.x + 3, y: bLine.end.y)
)

// Use a line segment and an endpoint to determine a triangle
struct Triangle {
    let line: Line
    let point: Point
}

// Triangle A
let aTriangle = Triangle(
    line: Line(
      start: Point(x: 10, y: 15),
      end: Point(x: 50, y: 15)
    ),
    point: Point(x: 20, y: 60))// Change the end point of triangle A to form an isosceles triangle B
let bTriangle = Triangle(
    line: Line(
        start: Point(x: aTriangle.line.start.x, y: aTriangle.line.start.y),
        end: Point(x: 30, y: aTriangle.line.end.y)
    ),
    point: Point(x: aTriangle.point.x, y: aTriangle.point.y)
)
Copy the code

As the above example shows, the deeper the data hierarchy, the more complex this “modify” method of creating new data from the original data becomes, and you end up with a lot of unnecessary template code that is extremely painful.

Lens is designed to solve this complex problem of “modifying” immutable data

Lens

define

Lens is simply defined as a function type:

typealias Lens<Subpart.Whole> = (@escaping (Subpart) - > (Subpart)) - > (Whole) - >Whole
Copy the code

The Whole generic refers to the type of the data structure itself, and Subpart refers to the type of a specific field in the structure.

To understand the Lens function, use some notation:

Lens = ((A) -> A') -> (B) -> B'

The Lens function receives A conversion function for the field (A) -> A’. We create A new field value A’ based on the old value A of the obtained field. When we pass this conversion function, Lens returns A function that maps the old data B to the new data B’. This is the “change” of immutable data by using old data to construct new data.

build

We can build Lens for each field:

extension Point {
    // Lens for the x field
    static let xL: Lens<CGFloat.Point> = { mapper in
        return { old in
            return Point(x: mapper(old.x), y: old.y)
        }
    }
    
    // Lens of the y field
    static let yL: Lens<CGFloat.Point> = { mapper in
        return { old in
            return Point(x: old.x, y: mapper(old.y))
        }
    }
}

extension Line {
    // Lens of the start field
    static let startL: Lens<Point.Line> = { mapper in
        return { old in
            return Line(start: mapper(old.start), end: old.end)
        }
    }
    
    // Lens for the end field
    static let endL: Lens<Point.Line> = { mapper in
        return { old in
            return Line(start: old.start, end: mapper(old.end))
        }
    }
}
Copy the code

Lens is a bit complicated to build, so we can create a function to initialize Lens more easily:

func lens<Subpart, Whole>(view: @escaping (Whole) -> Subpart.set: @escaping (Subpart.Whole) - >Whole) - >Lens<Subpart.Whole> {
    return { mapper in { set(mapper(view($0)), $0)}}}Copy the code

The lens function takes two arguments, both of which are function types, and the table represents the Getter and Setter functions for this field:

  • viewType:(B) -> A, B represents the data structure itself, and A represents A field in the data structure. The purpose of this function is to obtain the value of the specified field from the data structure itself.
  • setType:(A, B) -> B', A is the new field value obtained after transformation, B is the value of the old data structure, and B’ is the new data structure constructed based on the old data structure B and the new field value A.

Now we can use the lens function to build lens:

extension Point {
    static let xLens = lens(
       view: { $0.x }, 
       set: { Point(x: $0, y: $1.y) }
    )
    static let yLens = lens(
        view: { $0.y },
        set: { Point(x: $1.x, y: $0)})}extension Line {
    static let startLens = lens(
        view: { $0.start },
        set: { Line(start: $0, end: $1.end) }
    )
    static let endLens = lens(
        view: { $0.end }, 
        set: { Line(start: $1.start, end: $0)})}Copy the code

This is much cleaner than the Lens definition. We pass in the view parameter the method to get the field and in the set parameter the method to create the new data.

Set / Over

With Lens defined for each field, we can modify the data structure using the set and over functions:

let aPoint = Point(x: 2, y: 3)

// Set Point y to 5 (y = 5)
let setYTo5 = set(value: 5, lens: Point.yLens)
let bPoint = setYTo5(aPoint)

// This function moves Point to the right by 3 (x += 3)
let moveRight3 = over(mapper: { $0 + 3 }, lens: Point.xLens)
let cPoint = moveRight3(aPoint)
Copy the code

We can look at the code for the over and set functions:

func over<Subpart, Whole>(mapper: @escaping (Subpart) -> Subpart, lens: Lens<Subpart.Whole>) - > (Whole) - >Whole {
    return lens(mapper)
}

func set<Subpart, Whole>(value: Subpart, lens: Lens<Subpart, Whole>)- > (Whole) - >Whole {
    return over(mapper: { _ in value }, lens: lens)
}
Copy the code

Very simple: Over simply calls Lens, and set simply calls over, returning the new field value in the mapper argument passed to the over function.

combination

As mentioned earlier, Lens is used to optimize “change” operations for complex, multi-level data structures. How does Lens work for multi-level data structures? The answer is: combinations, and this is just ordinary combinations of functions. Here is the concept of function combination:

Function composition

There are functions f: (A) -> B and g: (B) -> C, if there is A value A of type A, we want to pass it through functions f and g to get A value C of type C, we can call: let C = g(f(A)). In A programming language where functions exist as first-class citizens, we might want to make such multilevel function calls more concise, so we introduce the concept of function composition: let h = g. f, where h is of type (A) -> C, which is A combination of functions f and g and is itself A function, and. The function of the operator is to combine two functions. After combining functions, we can call the new function with the original value: let c = h(a).

In Swift, we can define the following function combination operators:

func >>> <A, B, C> (lhs: @escaping (A) -> B, rhs: @escaping (B) - >C) - > (A) - >C {
    return { rhs(lhs($0))}}func <<< <A, B, C> (lhs: @escaping (B) -> C, rhs: @escaping (A) - >B) - > (A) - >C {
    return { lhs(rhs($0))}}Copy the code

The operators >>> and <<< are opposite in the types of the left and right operands, so g <<< f and f >>> g yield the same combinatorial function. Where >>> is the left associative operator and <<< is the right associative operator.

Lens combination

Lenses are functions themselves, so they can do normal function combinations:

let lineStartXLens = Line.startLens <<< Point.xLens
Copy the code

LineStartXLens The Lens field is the x coordinate of the beginning end of the Line segment line.start. x. We can analyze the combination process:

Line. StartLens As A Lens, the type is ((Point) -> Point -> (Line) -> Line, we can regard it as (A) -> B, where the type of A is (Point) -> Point, The type of B is (Line) -> Line. Point. XLens is ((CGFloat) -> CGFloat) -> Point -> Point (C) -> D The D type is (Point) -> Point. XLens is A function of (C) -> A. XLens is A function of (C) -> B. This is a new Lens of type ((CGFloat) -> CGFloat) -> (Line) -> Line.

Now you can use set or over to manipulate the new Lens:

// Move the starting endpoint of segment A 3 coordinates to the right
let startMoveRight3 = over(mapper: { $0 + 3 }, lens: lineStartXLens)
let bLine = startMoveRight3(aLine)
Copy the code

The operator

For code brevity, we can define the following operators for Lens:

func |> <A, B> (lhs: A, rhs: (A) -> B) -> B {
    return rhs(lhs)
}

func %~ <Subpart, Whole>(lhs: Lens<Subpart, Whole>, rhs: @escaping (Subpart) -> Subpart) -> (Whole) -> Whole {
    return over(mapper: rhs, lens: lhs)
}

func .~ <Subpart, Whole>(lhs: Lens<Subpart, Whole>, rhs: Subpart) -> (Whole) -> Whole {
    return set(value: rhs, lens: lhs)
}
Copy the code

What they do is:

  • |>The left-combined function uses the operator, which simply passes the value into the function to be called. The operator is used to reduce the number of parentheses when the function is called consecutively, and to improve the code’s aesthetics and readability.
  • % ~: The Lens is displayedoverFunction work.
  • . ~: The Lens is displayedsetFunction work.

Using the above operators, we can write Lens code that is more elegant and concise:

// What to do?
// 1. Move the starting endpoint of segment A to the right by 3 coordinates
// 2. Then move the stop point 5 coordinates to the left
// 3. Set the y coordinate of the end point to 9
let bLine = aLine
    |> Line.startLens <<< Point.xLens %~ { $0 + 3} | >Line.endLens <<< Point.xLens %~ { $0 - 5} | >Line.endLens <<< Point.yLens .~ 9
Copy the code

KeyPath

With Swift’s KeyPath feature, we were able to leverage the power of Lens. First we can extend KeyPath with Lens:

extension WritableKeyPath {
    var toLens: Lens<Value, Root> {
        return lens(view: { $0[keyPath: self] }, set: {
            var copy = $1
            copy[keyPath: self] = $0
            return copy
        })
    }
}

func %~ <Value, Root>(lhs: WritableKeyPath<Root, Value>, rhs: @escaping (Value) -> Value) -> (Root) -> Root {
    return over(mapper: rhs, lens: lhs.toLens)
}

func .~ <Value, Root>(lhs: WritableKeyPath<Root, Value>, rhs: Value) -> (Root) -> Root {
    return set(value: rhs, lens: lhs.toLens)
}
Copy the code

With KeyPath, we don’t need to define Lens for each specific field, just open the bag and eat it:

let formatter = DateFormatter()
    |> \.dateFormat .~ "yyyy-MM-dd"
    |> \.timeZone .~ TimeZone(secondsFromGMT: 0)
Copy the code

Since DateFormatter is a reference type, we typically configure it like this:

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)...Copy the code

Lens syntax is much cleaner than this traditional syntax, with the configuration of each object contained in a specific syntax block.

Note, however, that the KeyPath type that can be directly compatible with Lens can only be WritableKeyPath, so we will still create Lens for some of the field properties that use the let modifier.

link

TangentW/Lens | Lens for Swift – in this paper, the corresponding code

@Tangentsw – Please follow me on Twitter