Lazy Evaluation in Swift
Bài đăng này đã không được cập nhật trong 9 năm
Bài viết lấy từ blog nghialv.com
Đố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 when
và what
.
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:
func dosomething() -> Int {
println("dosomething")
return 1
}
func foo(x: Int, status: String) {
println("foo")
}
foo(dosomething(), "200")
// 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:
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")
// 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 map
và filter
như sau:
var array = Array(0...100000)
let doubles = array.map { i -> Int in
println("map")
return i * 2
}
let x = doubles[1]
println(x)
// 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:
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:
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")
// 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:
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 map
và filter
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:
var array = lazy(Array(0...1000))
let doubles = array.map { i -> Int in
println("map")
return i * 2
}
let x = doubles[1]
println(x)
// 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:
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")
// 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.
class Foo {
lazy var tmp: Int = {
println("tmp init")
return 1
}()
}
println("before init")
let foo = Foo()
println("after init")
foo.tmp
foo.tmp
// 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.
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
// 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 functionlazy
để tạo ra lazy collection/sequence
- đối với những funtion/method tự khai báo thì có thể dùng
- 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
All rights reserved