nghialv blog

practice makes perfect

Lazy Evaluation in Swift

| Comments

Đối với các ngôn ngữ lập trình hàm như Haskell thì lazy evaluation dường như rất phổ biến. Nhưng đối với các lập trình viên iOS, hay lập trình viên sử dụng một số ngôn ngữ khác chúng ta lại ít chú ý đến. Khi sử dụng Swift mình thấy Swift cũng hỗ trợ khá nhiều về lazy evaluation không hẳn chỉ là lazy property. Thế nhưng có lẽ do tài liệu về Swift chưa được nhiều hoặc ít chú ý đến lazy evaluation nên có thể chúng ta chưa áp dụng nhiều. Vậy nên bài viết này sẽ tập trung nói về các chiến lược lazy evaluation của Swift. Bài viết sẽ đi qua từng evaluation strategy, và mỗi strategy sẽ cố gắng tập hợp nhiều ví dụ khác nhau để chúng ta hiểu rõ và dễ áp dụng sau này.

Đầu tiên hãy cùng xem qua định nghĩa về evaluation strategy từ Wikipedia:

Ở đây chúng ta nhấn mạnh vào whenwhat.

What thì có lẽ quen thuộc hơn với 2 strategies nổi bật là call-by-value, call-by-reference (Tên gọi của các strategy trong bài viết này mình sử dụng như tại Wikipedia). Đối với Swift thì các đối số thuộc kiểu Class sẽ áp dụng call-by-reference còn các kiểu dữ liệu còn lại như String, Struct, Tuple, Enum, Int, … đều áp dụng call-by-value strategy. Như chúng ta đều biết với call-by-value thì trong hàm sẽ thao tác với 1 bản copy của đối số truyền vào nên không ảnh hưởng đến giá trị bên ngoài hàm. Còn call-by-reference thì thay cho việc copy mà thao tác với một reference tới instance của đối số nên mọi thay đổi bên trong hàm tới đối số sẽ ảnh hưởng đến biến bên ngoài hàm.

Còn When ở đấy muốn nói tới thời điểm đối số được evaluate, và sẽ là chủ đề chính của bài viết hôm nay. When thường thì chia làm 3 loại chính như sau:

  • Eager evaluation
  • Call by name
  • Call by need

Mình sẽ đi qua từng loại và kèm theo các ví dụ trong Swift, sau cùng sẽ rút ra kết luận về từng loại.

(Eager evaluation)

Đầu tiên mình hãy nhìn vào đoạn code đơn giản sau:

1
2
3
4
5
6
7
8
9
10
func dosomething() -> Int {
    println("dosomething")
    return 1
}

func foo(x: Int, status: String) {
    println("foo")
}

foo(dosomething(), "200")
1
2
3
// output
dosomething
foo

Nhìn vào output chúng ta thấy rằng hàm dosomething chạy trước hàm foo. Từ đấy có thể thấy đối số của foo đã bị evaluate trước khi body của hàm foo được thực hiện. Đây chính là eager evaluation strategy: đối số được evaluate trước khi truyền vào hàm. Tiếp theo chúng ta hãy cùng nhìn vào đoạn code sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
func expensiveComputation() -> Int {
    println("expensiveComputation")
    return 1
}

func foo(x: Int, status: String) {
    println("foo")
    let result = status == "200" ? x : 0

    println("result: \(result)")
}

foo(expensiveComputation(), "404")
1
2
3
4
// output
expensiveComputation
foo
result: 0

Điểm khác biệt đầu tiên so với đoạn code trước đó là chúng ta có 1 tình toán cực kỳ tốn chi phí expensiveComputation, và được truyền vào như một đối số x của hàm foo. Hơn nữa bản thân bên trong hàm foo có những trường hợp không cần dùng đến x (như ví dụ là khi status != “200”). Quả là sự phung phí không hề nhỏ khi mà tồn tại trường hợp x không cần dùng đến nhưng đối số x vẫn bị evaluate từ đầu dẫn đến hàm expensiveComputation vẫn bị thực hiện. Đây là ví dụ thứ nhất cho thấy vấn đề của eager evaluation.

Ví dụ tiếp theo khi sử dụng mapfilter như sau:

1
2
3
4
5
6
7
8
var array = Array(0...100000)
let doubles = array.map { i -> Int in
    println("map")
    return i * 2
}

let x = doubles[1]
println(x)
1
2
3
4
5
// output
map
... // (100001 lần)
map
0

Đây chỉ là ví dụ đơn giản để chúng ta dễ hiểu hơn. Còn hãy tưởng tượng bạn có 1 mảng khá lớn và sau khi qua các bước sử dụng map, filter để xử lý thì cuối cùng chúng ta chỉ sử dụng 1 số lượng phần tử nhỏ hơn nhiều so với số lượng mảng ban đầu. Nhưng tất cả các phần tử trong mảng đều đã bị evaluate cho dù tồn tại những phần tử không thực sự cần thiết phải evaluate.

Thêm một ví dụ nữa sử dụng function currying như đoạn code sau:

1
2
3
4
5
6
7
8
9
10
11
12
func dosomething() -> Int {
    println("dosomething")
    return 1
}

func foo(a: Int)(b: Int) {
    println("add")
}

let cFoo = foo(dosomething())

//cFoo(b: 10)

Sau khi truyền đối số thứ nhất cho hàm foo chúng ta sẽ nhận được 1 hàm mới là cFoo. Hàm cFoo này sẽ có nhiệm vụ nhận thêm 1 đối số còn lại của hàm foo bạn đầu và sau đó thực hiện xử lý bên trong foo. Các xử lý trong foo chỉ thực hiện khi chúng ta gọi cFoo với đối số còn lại là b. Thế nhưng với eager evaluation strategy thì đối số a bị evaluate tại thời điểm tạo hàm cFoo, ngay cả khi cFoo chưa được gọi. Dẫn tới thời điểm evaluate a, thời điểm evaluate b, thời điểm xử lý của hàm foo được thực hiện là hoàn toàn khác nhau.

Từ 3 ví dụ trên chúng ta thấy có những trường hợp chúng ta chỉ muốn evaluate khi thực sự cần thiết, hay chỉ muốn evaluate những biến cần dùng, hay có thể chúng ta muốn thay đổi thời điểm evaluate vào bên trong hàm. Điều này sẽ được giải quyết bởi call-by-name strategy.

(Call-by-name)

Để chuyển thời điểm evaluate vào trong hàm hay chỉ evaluate khi thực sự cần thiết, với Swift thì tuỳ trường hợp mà ta có cách khác nhau. Trường hợp function/method do chúng ta tự khai báo như ví dụ thứ nhất và thứ ba ở phần trước chúng ta có thể sửa lại cách khai báo đối số bằng cách wrap đối số bởi một closure như đoạn code sau:

1
2
3
4
5
6
7
8
9
10
11
12
func expensiveComputation() -> Int {
    println("expensiveComputation")
    return 1
}

func foo(x: () -> Int, status: String) {
    println("foo")
    let result = status == "200" ? x() : 0
    println("result: \(result)")
}

foo({ expensiveComputation() }, "404")
1
2
3
// output
foo
result: 0

Đối số đầu tiên của hàm foo không phải kiểu Int như ban đầu mà chuyển thành closure có kiểu () -> Int. Và khi cần evaluate chúng ta gọi closure x() để evaluate. Như vậy đối số x sẽ không bị evaluate trước khi hàm foo được thực hiện, và ngoài ra chỉ trong trường hợp status == "200" thì x mới bị evaluate và hàm expensiveComputation mới bị thực hiện. Tuy nhiên chú ý rằng hàm foo đã thay đổi khai báo nên khi gọi hàm foo chúng ta phải wrap đối số thứ nhất trong 1 closure có kiểu () -> Int như { expensiveComputation() }. Tuy nhiên việc gọi rườm rà này chúng ta có thể giải quyết bằng việc sử dụng thuộc tính @autoclosure khi khai báo hàm foo như sau:

1
2
3
4
5
6
7
8
9
10
11
12
func expensiveComputation() -> Int {
    println("expensiveComputation")
    return 1
}

func foo(x: @autoclosure () -> Int, status: String) {
    println("foo")
    let result = status == "200" ? x() : 0
    println("result: \(result)")
}

foo(expensiveComputation(), "404")

Nhờ có việc sử dụng @autoclosure mà khi gọi hàm foo chúng ta thấy code không có gì thay đổi so với bình thường.

Thế còn trường hợp như ví dụ thứ hai của phần trước thì sao. Khi mà chúng ta chỉ muốn evaluate những biến thực sự cần thiết, nhưng hàm mapfilter là do thư viện chuẩn của Swift cung cấp, chúng ta không thể thêm @autoclosure vào được. Tin vui đó là Swift cung cấp chúng ta function lazy() mà ít khi ta chú ý tới.

Ví dụ như để giải quyết vấn đề ở ví dụ trước chúng ta chỉ cần sử dụng function lazy để tạo ra lazy collection như sau:

1
2
3
4
5
6
7
8
var array = lazy(Array(0...1000))
let doubles = array.map { i -> Int in
    println("map")
    return i * 2
}

let x = doubles[1]
println(x)
1
2
3
// output
map
0

Và kết quả là chỉ những phần tử cần thiết mới phải evaluate.

Mình lại tiếp tục cùng xem đoạn code sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func expensiveComputation() -> Int {
    println("expensiveComputation")
    return 1
}

func foo(x: @autoclosure () -> Int, status: String) {
    println("foo")
    let result = status == "200" ? x() : 0

    let t1 = x()
    let t2 = x()

    println("result: \(result)")
}

foo(expensiveComputation(), "404")
1
2
3
4
5
// output
foo
expensiveComputation
expensiveComputation
result: 0

Ta thấy rằng mỗi lần access thì x lại bị evaluate lại tức là hàm expensiveComputation bị thực hiện lại. Không ít những trường hợp mà chúng ta muốn tránh việc evaluate lại như thế. Đấy chính là điểm khác biệt của call-by-need so với call-by-name.

(Call-by-need)

Giống như call-by-name strategy, call-by-need cũng chỉ evaluate biến khi thực sự cần thiết, thế nhưng việc evaluate chỉ thực hiện lần đầu và kết quả được lưu lại và sử dụng cho những lần access tiếp theo. Và dễ nhận thấy nhất là lazy property cũng sử dụng call-by-need strategy.

1
2
3
4
5
6
7
8
9
10
11
12
class Foo {
    lazy var tmp: Int = {
            println("tmp init")
            return 1
        }()
}

println("before init")
let foo = Foo()
println("after init")
foo.tmp
foo.tmp
1
2
3
4
// output
before init
after init
tmp init

Nhìn vào kết quả output ta thấy property tmp chỉ được evaluate khi cần thiết (khi acccess foo.tmp). Ngoài ra việc evaluate chỉ thực hiện một lần duy nhất, những lần access sau đều sử dụng giá trị đã evaluate ở lần đầu tiên. Ngoài lazy property thì global variable hay static property đều mặc định áp dụng call-by-need strategy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Foo {
    static var tmp: Int = {
            println("tmp init")
            return 1
        }()

    static func log() {
        println("foo")
    }
}

let foo = Foo()
Foo.log()
println("access tmp")
let x = Foo.tmp
1
2
3
4
// output
foo
access tmp
tmp init
Kết luận
  • Eager evaluation:
    • evaluation được thực hiện trước khi truyền vào hàm
  • Call-by-name:
    • evaluation thực hiện trong hàm hay chỉ evaluate khi thực sư cần thiết
    • thế nhưng việc evaluation sẽ bị thực hiện lại mỗi khi access
    • cách áp dụng call-by-name strategy:
      • đối với những funtion/method tự khai báo thì có thể dùng @autoclosure để thực hiện call-by-name strategy
      • khi sử dụng map, filter chúng ta có thể sử dụng function lazy để tạo ra lazy collection/sequence
  • Call-by-need:
    • evaluation chỉ thực hiện khi cần thiết
    • lazy evaluation, global variable, static property mặc định áp dụng strategy này

Comments