Skip to content

Hàm trong Go

Trong Go, hàm là công dân hạng nhất, hàm là thành phần cơ bản nhất của Go, cũng là cốt lõi của Go.

Khai báo

Định dạng khai báo hàm như sau

go
func tên_hàm([danh_sách_tham_số]) [giá_trị_trả_về] {
  thân_hàm
}

Có hai cách để khai báo hàm, một là khai báo trực tiếp bằng từ khóa func, hai là khai báo bằng từ khóa var, như dưới đây

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

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

Chữ ký hàm bao gồm tên hàm, danh sách tham số, giá trị trả về, dưới đây là một ví dụ hoàn chỉnh, tên hàm là Sum, có hai tham số kiểu inta, b, kiểu giá trị trả về là int.

go
func Sum(a int, b int) int {
   return a + b
}

Còn một điểm vô cùng quan trọng, tức là hàm trong Go không hỗ trợ overload, mã như dưới đây không thể biên dịch

go
type Person struct {
  Name    string
  Age     int
  Address string
  Salary  float64
}

func NewPerson(name string, age int, address string, salary float64) *Person {
  return &Person{Name: name, Age: age, Address: address, Salary: salary}
}

func NewPerson(name string) *Person {
  return &Person{Name: name}
}

Triết lý của Go là: nếu chữ ký khác nhau thì đó là hai hàm hoàn toàn khác nhau, vậy thì không nên đặt cùng một tên, overload hàm sẽ làm mã trở nên khó hiểu và khó hiểu. Triết lý này có đúng hay không còn tùy quan điểm, ít nhất trong Go bạn có thể chỉ thông qua tên hàm là biết nó làm gì, mà không cần phải tìm xem nó rốt cuộc là overload nào.

Tham số

Tên tham số trong Go có thể không mang tên, thường là khi khai báo interface hoặc kiểu hàm mới dùng đến, nhưng để dễ đọc thường vẫn nên thêm tên cho tham số

go
type ExWriter func(io.Writer) error

type Writer interface {
  ExWrite([]byte) (int, error)
}

Đối với các tham số có cùng kiểu, có thể chỉ cần khai báo kiểu một lần, nhưng điều kiện là chúng phải liền kề

go
func Log(format string, a1, a2 any) {
  ...
}

Tham số biến độ dài có thể nhận 0 hoặc nhiều giá trị, phải được khai báo ở cuối danh sách tham số, ví dụ điển hình nhất là hàm fmt.Printf.

go
func Printf(format string, a ...any) (n int, err error) {
  return Fprintf(os.Stdout, format, a...)
}

Đáng nói là, tham số hàm trong Go là truyền theo giá trị, tức là khi truyền tham số sẽ sao chép giá trị của đối số thực. Nếu bạn nghĩ rằng khi truyền slice hoặc map sẽ sao chép một lượng lớn bộ nhớ, tôi có thể nói với bạn không cần lo lắng, vì hai cấu trúc dữ liệu này về bản chất đều là con trỏ.

Giá trị trả về

Dưới đây là một ví dụ đơn giản về giá trị trả về của hàm, hàm Sum trả về một giá trị kiểu int.

go
func Sum(a, b int) int {
   return a + b
}

Khi hàm không có giá trị trả về, không cần void, không mang giá trị trả về là được.

go
func ErrPrintf(format string, a ...any) {
  _, _ = fmt.Fprintf(os.Stderr, format, a...)
}

Go cho phép hàm có nhiều giá trị trả về, lúc này cần dùng ngoặc đơn để bao các giá trị trả về lại.

go
func Div(a, b float64) (float64, error) {
  if a == 0 {
    return math.NaN(), errors.New("0 không thể làm số bị chia")
  }
  return a / b, nil
}

Go cũng hỗ trợ giá trị trả về có tên, không được trùng với tên tham số, khi sử dụng giá trị trả về có tên, từ khóa return có thể không cần chỉ định trả về những giá trị nào.

go
func Sum(a, b int) (ans int) {
  ans = a + b
  return
}

Giống như tham số, khi có nhiều giá trị trả về có tên cùng kiểu, có thể bỏ qua khai báo kiểu trùng lặp

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
  return
}

Bất kể giá trị trả về có tên được khai báo như thế nào, luôn luôn ưu tiên giá trị sau từ khóa return.

go
func SumAndMul(a, b int) (c, d int) {
  c = a + b
  d = a * b
    // c, d sẽ không được trả về
  return a + b, a * b
}

Hàm ẩn danh

Hàm ẩn danh là hàm không có chữ ký, ví dụ như hàm func(a, b int) int dưới đây, nó không có tên, vì vậy chúng ta chỉ có thể gọi nó bằng cách thêm ngoặc đơn ngay sau thân hàm.

go
func main() {
   func(a, b int) int {
      return a + b
   }(1, 2)
}

Khi gọi một hàm, khi tham số của nó là một kiểu hàm, lúc này tên không còn quan trọng nữa, có thể trực tiếp truyền một hàm ẩn danh, như dưới đây

go
type Person struct {
  Name   string
  Age    int
  Salary float64
}

func main() {
  people := []Person{
    {Name: "Alice", Age: 25, Salary: 5000.0},
    {Name: "Bob", Age: 30, Salary: 6000.0},
    {Name: "Charlie", Age: 28, Salary: 5500.0},
  }

  slices.SortFunc(people, func(p1 Person, p2 Person) int {
    if p1.Name > p2.Name {
      return 1
    } else if p1.Name < p2.Name {
      return -1
    }
    return 0
  })
}

Đây là một ví dụ về quy tắc sắp xếp tùy chỉnh, slices.SortFunc nhận hai tham số, một là slice, hai là hàm so sánh, nếu không xét đến việc tái sử dụng, chúng ta có thể trực tiếp truyền hàm ẩn danh.

Closure

Closure (bao đóng), trong một số ngôn ngữ còn được gọi là biểu thức Lambda, được sử dụng cùng với hàm ẩn danh, closure = hàm + tham chiếu môi trường, xem một ví dụ dưới đây:

go
func main() {
  grow := Exp(2)
  for i := range 10 {
    fmt.Printf("2^%d=%d\n", i, grow())
  }
}

func Exp(n int) func() int {
  e := 1
  return func() int {
    temp := e
    e *= n
    return temp
  }
}

Xuất ra

2^0=1
2^1=2
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^7=128
2^8=256
2^9=512

Giá trị trả về của hàm Exp là một hàm, ở đây sẽ gọi là hàm grow, mỗi khi gọi nó một lần, biến e sẽ tăng theo cấp số nhân một lần. Hàm grow tham chiếu hai biến của hàm Exp: en, chúng được sinh ra trong phạm vi của hàm Exp, trong trường hợp bình thường khi kết thúc gọi hàm Exp, bộ nhớ của các biến này sẽ được thu hồi khi ra khỏi stack. Nhưng do hàm grow tham chiếu chúng, nên chúng không thể bị thu hồi, mà thoát ra heap, ngay cả khi vòng đời của hàm Exp đã kết thúc, nhưng vòng đời của biến en chưa kết thúc, trong hàm grow vẫn có thể trực tiếp sửa đổi hai biến này, hàm grow là một hàm closure.

Sử dụng closure, có thể rất đơn giản để thực hiện một hàm tính dãy Fibonacci, mã như sau

go
func main() {
    // 10 số Fibonacci
  fib := Fib(10)
  for n, next := fib(); next; n, next = fib() {
    fmt.Println(n)
  }
}

func Fib(n int) func() (int, bool) {
  a, b, c := 1, 1, 2
  i := 0
  return func() (int, bool) {
    if i >= n {
      return 0, false
    } else if i < 2 {
      f := i
      i++
      return f, true
    }

    a, b = b, c
    c = a + b
    i++

    return a, true
  }
}

Xuất ra

0
1
1
2
3
5
8
13
21
34

Gọi trì hoãn

Từ khóa defer có thể khiến một hàm được gọi trì hoãn một khoảng thời gian, trước khi hàm trả về, các hàm được mô tả bởi defer này cuối cùng sẽ được thực thi lần lượt, xem một ví dụ dưới đây

go
func main() {
  Do()
}

func Do() {
  defer func() {
    fmt.Println("1")
  }()
  fmt.Println("2")
}

Xuất ra

2
1

Vì defer được thực thi trước khi hàm trả về, bạn cũng có thể sửa đổi giá trị trả về của hàm trong defer

go
func main() {
  fmt.Println(sum(3, 5))
}

func sum(a, b int) (s int) {
  defer func() {
    s -= 10
  }()
  s = a + b
  return
}

Khi có nhiều hàm được mô tả bởi defer, sẽ thực thi theo thứ tự vào sau ra trước như stack.

go
func main() {
  fmt.Println(0)
  Do()
}

func Do() {
  defer fmt.Println(1)
  fmt.Println(2)
  defer fmt.Println(3)
  defer fmt.Println(4)
  fmt.Println(5)
}
0
2
5
4
3
1

Gọi trì hoãn thường được sử dụng để giải phóng tài nguyên tệp, đóng kết nối mạng, v.v., còn một cách dùng nữa là bắt panic, nhưng đây là thứ sẽ được đề cập trong phần xử lý lỗi.

Vòng lặp

Mặc dù không bị cấm rõ ràng, nhưng thường không nên sử dụng defer trong vòng lặp for, như dưới đây

go
func main() {
  n := 5
  for i := range n {
    defer fmt.Println(i)
  }
}

Xuất ra như sau

4
3
2
1
0

Kết quả của đoạn mã này là đúng, nhưng quá trình có lẽ không đúng. Trong Go, mỗi khi tạo một defer, cần申请 một vùng bộ nhớ trong goroutine hiện tại. Giả sử trong ví dụ trên không phải là vòng lặp for n đơn giản, mà là một quy trình xử lý dữ liệu phức tạp hơn, khi số lượng yêu cầu bên ngoài đột ngột tăng cao, thì trong thời gian ngắn sẽ tạo ra một lượng lớn defer, khi số lần vòng lặp lớn hoặc số lần không xác định, có thể dẫn đến việc sử dụng bộ nhớ đột ngột tăng cao, điều này chúng ta thường gọi là rò rỉ bộ nhớ.

Tính toán trước tham số

Đối với gọi trì hoãn có một số chi tiết phản trực giác, ví dụ như ví dụ dưới đây

go
func main() {
  defer fmt.Println(Fn1())
  fmt.Println("3")
}

func Fn1() int {
  fmt.Println("2")
  return 1
}

Cái bẫy này vẫn rất kín đáo, trước đây tác giả đã từng vì cái bẫy này mà nửa ngày không tra ra được nguyên nhân, có thể đoán xem xuất ra là gì, đáp án như sau

2
3
1

Có lẽ rất nhiều người cho rằng xuất ra là như dưới đây

3
2
1

Theo ý định ban đầu của người sử dụng, phần fmt.Println(Fn1()) này nên được thực thi sau khi kết thúc thực thi thân hàm, fmt.Println đúng là được thực thi cuối cùng, nhưng Fn1() lại nằm ngoài dự liệu, tình huống của ví dụ dưới đây càng rõ ràng hơn.

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer fmt.Println(sum(a, b))
  a = 3
  b = 4
}

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

Xuất ra của nó nhất định là 3 chứ không phải 7, nếu sử dụng closure thay vì gọi trì hoãn, kết quả lại khác

go
func main() {
  var a, b int
  a = 1
  b = 2
  f := func() {
    fmt.Println(sum(a, b))
  }
  a = 3
  b = 4
  f()
}

Xuất ra của closure là 7, vậy nếu kết hợp gọi trì hoãn và closure thì sao

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func() {
    fmt.Println(sum(a, b))
  }()
  a = 3
  b = 4
}

Lần này thì bình thường, xuất ra là 7. Dưới đây lại sửa một chút, không còn closure nữa

go
func main() {
  var a, b int
  a = 1
  b = 2
  defer func(num int) {
    fmt.Println(num)
  }(sum(a, b))
  a = 3
  b = 4
}

Xuất ra lại biến về 3. Thông qua so sánh một vài ví dụ trên có thể phát hiện đoạn mã này

defer fmt.Println(sum(a,b))

Thực chất tương đương với

defer fmt.Println(3)

Go sẽ không đợi đến cuối cùng mới gọi hàm sum, hàm sum đã được gọi trước khi gọi trì hoãn được thực thi, và được truyền làm tham số cho fmt.Println. Tóm lại, đối với hàm mà defer tác động trực tiếp, tham số của nó sẽ được tính toán trước, điều này cũng dẫn đến hiện tượng kỳ lạ trong ví dụ đầu tiên, đối với tình huống này, đặc biệt là trường hợp giá trị trả về của hàm làm tham số trong gọi trì hoãn cần đặc biệt lưu ý.

Golang by www.golangdev.cn edit