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
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
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 int là a, b, kiểu giá trị trả về là int.
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
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ố
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ề
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.
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.
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.
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.
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.
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
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.
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.
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
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:
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=512Giá 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: e và n, 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 e và n 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
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
34Gọ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
func main() {
Do()
}
func Do() {
defer func() {
fmt.Println("1")
}()
fmt.Println("2")
}Xuất ra
2
1Vì 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
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.
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
1Gọ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
func main() {
n := 5
for i := range n {
defer fmt.Println(i)
}
}Xuất ra như sau
4
3
2
1
0Kế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
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
1Có lẽ rất nhiều người cho rằng xuất ra là như dưới đây
3
2
1Theo ý đị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.
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
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
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
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 ý.
