Go – Functional programming

Trong bài viết này, chúng ta cùng nhau tìm hiểu về Functional Programming trong Go. Chúng ta không đi sâu về khái niệm FP (Functional Programming), thay vào đó sẽ tập trung vào những thứ mà Go có thể làm.

Trong FP có 2 nguyên tắc vô cùng quan trọng:

  • Dữ liệu không thay đổi: Nghĩa là dữ liệu của object không nên thay đổi sau khi tạo
  • Không có state ngầm: Nên tránh state ẩn hoặc ngầm. Trong FP state không bị loại bỏ, thay vào đó, nó hiển thị và rõ ràng

Điều này nghĩa là:

  • No side effect: Một hàm hoặc phương thức không nên có bất kỳ state nào nằm ngoài phạm vi của hàm. Một  hàm chỉ nên trả về một giá trị cho bên gọi và không nên bị ảnh hưởng bởi state bên ngoài. Điều này giúp cho chương trình dễ hiểu hơn.
  • Pure function: Code của hàm không nên thay đổi giá trị. Các hàm chỉ nên trả về các giá trị dựa trên các tham số truyền vào và không nên bị ảnh hưởng (side effect) hoặc dựa trên state chung. Những hàm như thế sẽ luôn trả về cùng một kết quả với cùng một tham số truyền vào.

Ngoài ra còn những khái niệm bên dưới đây mà chúng ta có thể áp dụng trong Go,

Sử dụng FP không có nghĩa là toàn bộ hoặc không đáng kể, bạn có thể thường xuyên sử dụng khái niệm FP để bổ sung cho hướng đối tượng hoặc khái niệm bắt buộc trong Go. Lợi ích của FP có thể được tận dụng bất cứ khi nào có thể bất kể bạn đang sử dụng mô hình hay ngôn ngữ nào.

Sửa và tạo – string

Sai

name := "Geison"
name := name + " Flores"

Đúng

const firstname = "Geison"
const lasname = "Flores"
const name = firstname + " " + lastname

Sửa và tạo – mảng

Sai

years := [4]int{2001, 2002}
years[2] = 2003
years[3] = 2004
years // [2001, 2002, 2003, 2004]

Đúng

years := [2]int{2001, 2001}
allYears := append(years, 2003, [2]int{2004, 2005}

Sửa và tạo – map

Sai

ages := map[string]int{“John”: 30}
ages[“Mary”] = 28
ages // {‘John’: 30, ‘Mary’: 28}

Đúng

ages1 := map[string]int{“John”: 30}
ages2 := map[string]int{“Mary”: 28}
func mergeMaps(mapA, mapB map[string]int) map[string]int {
    allAges := make(map[K]V, len(ages1) + len(ages2))
    for k, v := range mapA {
        allAges[k] = v
    }
    for k, v := range mapB {
        allAges[k] = v
    }
    return allAges
}
allAges := mergeMaps(ages1, ages2)

First-class và higher order function

Các hàm First-class nghĩa là bạn có thể gán các hàm cho các biến, truyền vào một hàm như một tham số cho một hàm khác hoặc trả về một hàm từ một hàm khác. Go hỗ trợ điều này và do đó đưa ra các khái niệm như là closures, currying, và higher-order-function một cách dễ dàng khi code.

Một hàm chỉ được coi như một higher-order-function nếu nó nhận một hoặc nhiều hàm như một tham số và nếu nó trả về một hàm khác như một kết quả.

Trong Go bạn có thể thực hiện như sau

func main() {
    var list = []string{"Orange", "Apple", "Banana", "Grape"}
    // chúng ta đang truyền một mảng và một hàm như tham số cho phương thức mapForEach.
    var out = mapForEach(list, func(it string) int {
        return len(it)
    })
    fmt.Println(out) // [6, 5, 6, 5]
}

// Higher-order-function nhận một mảng và hàm như một tham số
func mapForEach(arr []string, fn func(it string) int) []int {
    var newArray = []int{}
    for _, it := range arr {
        // Chúng ta đang chạy phương thức được truyền
        newArray = append(newArray, fn(it))
    }
    return newArray
}

Closures và currying cũng có thể sử dụng trong Go

// Đây là một higher-order-function trả về một hàm
func add(x int) func(y int) int {
    // Một hàm được trả về như closure
    // Biến x được lấy từ bên ngoài phương thức và bộ nhớ trong closure
    return func(y int) int {
        return x + y
    }
}

func main() {

    // Sử dụng currying cho phương thức add đêt tạo thêm các biến
    var add10 = add(10)
    var add20 = add(20)
    var add30 = add(30)

    fmt.Println(add10(5)) // 15
    fmt.Println(add20(5)) // 25
    fmt.Println(add30(5)) // 35
}

Ngoài ra cũng có nhiều higher-order-function tích hợp trong thư viện chuẩn của Go. Cũng có nhiều thư viện hỗ trợ theo kiểu functional như là koazee hoặc itertools hỗ trợ map-reduce như là các phương thức functional trong Go.

Pure function

Như chúng ta đã hiểu về Pure Function chỉ nên trả về các giá trị dựa trên các tham số truyền vào và không nên bị ảnh hưởng hoặc dựa vào state chung. Chúng ta có thể thực hiện dễ dàng trong Go.

Điều này tương đối đơn giản, bên dưới đây là một Pure Function. Nó luôn luôn trả về cùng một kết quả với cùng một đầu vào và hoạt động của nó tương đối dễ hiểu. Chúng ta có thể cache phương thức nếu cần

func sum(a, b int) int {
    return a + b
}

Giả sử chúng ta chỉnh sửa code bên trên bằng cách thêm vào như bên dưới đây, hoạt động của hàm trở nên khó dự đoán vì giờ đây nó bị side effect, bi ảnh hưởng bởi state bên ngoài.

var holder = map[string]int{}

func sum(a, b int) int {
    c := a + b
    holder[fmt.Sprintf("%d+%d", a, b)] = c
    return c
}

Nên hãy cố giữ cho hàm của bạn thuần tuý (Pure Function) và đơn giản.

Recursion – Đệ quy

FP khuyến khích sử dụng đệ quy thay vì dùng vòng lặp. Hãy cùng xem ví dụ sau đây dùng để tính toán giai thừa.

Tiếp cận truyền thống sử dụng vòng lặp

func factorial(num int) int {
    result := 1
    for ; num > 0; num-- {
        result *= num
    }
    return result
}

func main() {
    fmt.Println(factorial(20)) // 2432902008176640000
}

Chúng ta có thể hoàn thành đoạn code bên trên sử dụng đệ quy như bên dưới theo khuyến khích của FP

func factorial(num int) int {
    if num == 0 {
        return 1
    }
    return num * factorial(num-1)
}
func main() {
    fmt.Println(factorial(20)) // 2432902008176640000
}

Nhược điểm của tiếp cận sử dụng đệ quy là nó sẽ chậm hơn khi so sánh với vòng lặp trong hầu hết các lần (nhưng ưu điểm mà chúng ta nhắm đến là code trông đơn giản và dễ đọc) và có thể dẫn đến lỗi tràn bộ nhớ do tất cả các lần gọi hàm đều cần được lưu như một frame cho stack. Để tránh điều này nên dùng đệ quy đuôi (tail recursion) hơn và đặc biệt với trường hợp phải sử dụng đệ quy nhiều lần. Đệ quy đuôi giúp tránh được tạo Stack mới khi lần gọi cuối cùng trong đệ quy là hàm chính nó. Hầu hết trình biên dịch có thể tối ưu hoá code đệ quy đuôi giống như cách vòng lặp được tối ưu hoá do đó tránh được vấn đề hiệu suất. Đáng tiếc trình biên dịch của Go không tối ưu cho điều này.

Bây giờ chúng ta sẽ thử sử dụng đệ quy đuôi cho hàm như bên dưới đây

func factorialTailRec(num int) int {
    return factorial(1, num)
}

func factorial(accumulator, val int) int {
    if val == 1 {
        return accumulator
    }
    return factorial(accumulator*val, val-1)
}

func main() {
    fmt.Println(factorialTailRec(20)) // 2432902008176640000
}

Hãy cân nhắc khi sử dụng đệ quy khi viết code Go sao cho dễ đọc và tính bất biến, nhưng hãy cẩn trọng hiệu suất hoặc nếu số vòng lặp lớn thì nên chuyển sang dùng vòng lặp.

Lazy evaluation – tính toán Lazy

Hãy cùng tìm hiểu một chút về tính toán Eager vs Lazy

  • Tính toán Eager: Biểu thức được tính toán tại thời điểm biến được gán hoặc hàm được gọi…
  • Tính toán Lazy: Trì hoãn việc tính toán cho tới khi nó cần thiết.
  • Tính hiệu quả bộ nhớ: Không có bộ nhớ nào được sử dụng để lưu trữ hoàn toàn cấu trúc
  • Tính hiệu quả CPU: Không cần tính toán hoàn toàn kết quả trước khi trả về

Tính toán Lazy là quá trình trì hoãn việc tính toán của một biểu thức cho tới khi nó thật sự cần. Nói chung, về cơ bản thì Go tính toán Eager nhưng đối với các toán tử như là && và || nó thực hiện tính toán Lazy. Chúng ta có thể sử dụng higher-order-function, closure, goroutine, và channel bắt chước tính toán Lazy.

Hãy cùng xem ví dụ sau đây

func main() {
    fmt.Println(addOrMultiply(true, add(4), multiply(4)))  // 8
    fmt.Println(addOrMultiply(false, add(4), multiply(4))) // 16
}

func add(x int) int {
    fmt.Println("executing add") // được in ra do hàm được tính toán trước
    return x + x
}

func multiply(x int) int {
    fmt.Println("executing multiply") // được in ra do hàm được tính toán trước
    return x * x
}

func addOrMultiply(add bool, onAdd, onMultiply int) int {
    if add {
        return onAdd
    }
    return onMultiply
}

Kết quả được hiển thị như bên dưới và cả hai hàm đang thực thi bình thường

executing add
executing multiply
8
executing add
executing multiply
16

Chúng ta có thể sử dụng higher-order-function để viết lại theo phiên bản tính toán lazy

func add(x int) int {
    fmt.Println("executing add")
    return x + x
}

func multiply(x int) int {
    fmt.Println("executing multiply")
    return x * x
}

func main() {
    fmt.Println(addOrMultiply(true, add, multiply, 4))
    fmt.Println(addOrMultiply(false, add, multiply, 4))
}

// đây là một higher-order-function do đó việc tính toán của hàm được trì hoàn trong if-else
func addOrMultiply(add bool, onAdd, onMultiply func(t int) int, t int) int {
    if add {
        return onAdd(t)
    }
    return onMultiply(t)
}

Kết quả

executing add
8
executing multiply
16

Có một cách khác để thực hiện điều này là sử dụng Sync & Futures như link này và sử dụng goroutine và channel như link này. Thực hiện tính toán lazy trong Go có thể là không hữu ích với code phức tạp nhưng nếu hàm nặng về xử lý thì nó chắc chắn là hữu ích khi sử dụng tính toán lazy.

Kiểu hệ thống

Go có kiểu hệ thống mạnh và cũng có kiểu interface hợp lệ. Nhưng điều duy nhất còn thiếu khi so sánh với ngôn ngữ lập trình FP những thứ như là Pattern Matching và Case Class.

Minh bạch tham chiếu

Đáng tiếc là không có nhiều cách để giới hạn việc thay đổi dữ liệu trong Go, tuy nhiên bằng cách sử dụng Pure function và bằng cách rõ ràng cũng giúp tránh được thay đổi dữ liệu và được gán lại sử dụng khái niệm khác mà chúng ta sẽ sớm được nhìn thấy. Go mặc định truyền các biến bằng giá trị ngoại trừ slice và map. Nên cần hạn chế truyền vào như tham chiếu (con trỏ) nhiều nhất có thể.

Ví dụ, bên dưới đây state bên ngoài sẽ thay đổi khi chúng ta truyền tham số bằng tham chiếu và do đó không đảm bảo tính minh bạch tham chiếu.

func main() {
    type Person struct {
        firstName string
        lastName  string
        fullName  string
        age       int
    }
    var getFullName = func(in *Person) string {
        in.fullName = in.firstName + in.lastName // dữ liệu thay đổi
        return in.fullName
    }

    john := Person{
        "john", "doe", "", 30,
    }

    fmt.Println(getFullName(&john)) // johndoe
    fmt.Println(john) // {john doe johndoe 30}
}

Nếu chúng ta truyền vào tham số bằng giá trị chúng ta có thể đảm bảo tính minh bạch thậm trí nếu có thay đổi dữ liệu của tham số bên trong hàm

func main() {
    type Person struct {
        firstName string
        lastName  string
        fullName  string
        age       int
    }
    var getFullName = func(in Person) string {
        in.fullName = in.firstName + in.lastName
        return in.fullName
    }

    john := Person{
        "john", "doe", "", 30,
    }

    fmt.Println(getFullName(john))
    fmt.Println(john)
}

Chúng ta không thể sử dụng cách này khi các tham số được truyền là slice hoặc map.

Cấu trúc dữ liệu

Khi sử dụng kỹ thuật FP nó thường khuyến khích sử dụng kiểu dữ liệu functional như là Stack, Map và Queue. Do đó map là tốt hơn array hoặc hash set trong FP dưới dạnh lưu trữ dữ liệu.

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.