Hàm là một yếu tố quan trọng trong Go, và hiểu và nắm vững quy trình gọi hàm là điều không thể bỏ qua khi học sâu về Go. Phần này sẽ giới thiệu quy ước gọi hàm và phương pháp truyền tham số của hàm.
Quy ước gọi hàm
Dù là ngôn ngữ lập trình hệ thống như C và Go, hay là ngôn ngữ kịch bản như Ruby và Python, các ngôn ngữ này thường sử dụng cú pháp tương tự khi gọi hàm:
Mặc dù cú pháp gọi hàm tương tự, nhưng quy ước gọi hàm có thể khác nhau. Quy ước gọi hàm là các quy tắc mà bên gọi và bên được gọi thống nhất về cách truyền tham số và trả về giá trị. Trong phần này, chúng ta sẽ tìm hiểu về quy ước gọi hàm trong C và Go.
Ngôn ngữ C
Trước tiên, chúng ta sẽ tìm hiểu quy ước gọi hàm trong ngôn ngữ C. Một cách tốt nhất để hiểu quy ước gọi hàm là biên dịch mã C thành mã hợp ngữ bằng gcc hoặc clang và phân tích quy ước gọi hàm từ mã hợp ngữ. Từ mã hợp ngữ, chúng ta có thể hiểu quy trình gọi hàm cụ thể.
Biên dịch cùng một mã ngôn ngữ C với gcc và clang có thể tạo ra các hướng dẫn hợp ngữ khác nhau, nhưng mã được tạo sẽ không có nhiều khác biệt về cấu trúc, vì vậy nó không ảnh hưởng nhiều đến những người chỉ muốn hiểu quy ước gọi. Tác giả chọn sử dụng trình biên dịch gcc để biên dịch ngôn ngữ C trong phần này:
Chúng ta sẽ sử dụng trình biên dịch gcc để biên dịch mã C sau đây thành mã hợp ngữ:
Chúng ta có thể sử dụng lệnh gcc -S my_function.c
để biên dịch tệp tin trên thành mã hợp ngữ như sau:
Chúng ta có thể phân tích quy trình gọi hàm theo thứ tự trước, trong và sau khi gọi hàm:
- Trước khi gọi
my_function
, hàm gọimain
sẽ lưu hai tham số củamy_function
vào các thanh ghiedi
vàesi
. - Khi gọi
my_function
, nó sẽ lưu dữ liệu từ các thanh ghi edi và esi vào các thanh ghi eax và edx, sau đó tính tổng của hai tham số đầu vào bằng lệnhaddl
. - Sau khi gọi
my_function
, giá trị trả về sẽ được truyền qua thanh ghi eax, và hàm gọimain
sẽ lưu giá trị trả về củamy_function
vào biếni
trên ngăn xếp.
Trong đoạn mã trên, khi số lượng tham số của my_function
tăng lên tám, việc biên dịch lại chương trình sẽ tạo ra mã hợp ngữ khác:
Khi hàm main
gọi hàm my_function
, sáu tham số đầu tiên sẽ được truyền qua sáu thanh ghi edi, esi, edx, ecx, r8d và r9d. Thứ tự sử dụng các thanh ghi cũng là một phần của quy ước gọi hàm, với tham số đầu tiên của hàm sử dụng thanh ghi edi, tham số thứ hai sử dụng thanh ghi esi, và cứ tiếp tục như vậy.
Hai tham số cuối cùng khác hoàn toàn so với các tham số trước đó, hàm gọi main
truyền hai tham số này thông qua ngăn xếp. Hình dưới đây mô tả thông tin ngăn xếp trước khi hàm main
gọi hàm my_function
:
Trong hình ảnh trên, thanh ghi rbp sẽ lưu trữ con trỏ cơ sở của ngăn xếp cuộc gọi hàm, tức là vị trí bắt đầu của không gian ngăn xếp thuộc về hàm main
. Trong khi đó, thanh ghi rsp lưu trữ vị trí kết thúc của ngăn xếp cuộc gọi hàm của hàm main
. Cả hai thanh ghi này cùng đại diện cho không gian ngăn xếp của hàm.
Trước khi gọi hàm my_function
, hàm main
sẽ cấp phát 16 byte địa chỉ ngăn xếp bằng lệnh subq $16, %rsp
, sau đó lưu trữ các tham số từ thứ sáu trở đi theo thứ tự từ phải sang trái vào ngăn xếp, tức là tham số thứ tám và tham số thứ bảy. Sáu tham số còn lại sẽ được truyền qua thanh ghi. Tiếp theo, lệnh call my_function
sẽ gọi hàm my_function
:
Trong hàm my_function
, trước tiên, tất cả dữ liệu trong thanh ghi được chuyển vào ngăn xếp. Sau đó, hàm sử dụng thanh ghi eax để tính tổng của tất cả các tham số đầu vào và trả về kết quả.
Chúng ta có thể tóm tắt phân tích và khám phá trong phần này như sau - khi sử dụng ngôn ngữ C trên máy x86_64, các tham số được truyền qua thanh ghi và ngăn xếp, với các đặc điểm sau:
- Sáu tham số hoặc ít hơn sẽ được truyền qua sáu thanh ghi edi, esi, edx, ecx, r8d và r9d theo thứ tự.
- Sáu tham số trở lên sẽ được truyền qua ngăn xếp, các tham số sẽ được lưu trữ vào ngăn xếp theo thứ tự từ phải sang trái.
Giá trị trả về của hàm được truyền qua thanh ghi eax. Vì chỉ sử dụng một thanh ghi để lưu trữ giá trị trả về, nên hàm trong ngôn ngữ C không thể trả về nhiều giá trị cùng một lúc.
Ngôn ngữ Go
Sau khi phân tích quy ước gọi hàm trong ngôn ngữ C, chúng ta sẽ tiếp tục phân tích quy ước gọi hàm trong ngôn ngữ Go. Chúng ta sẽ phân tích một đoạn mã rất đơn giản như sau:
Trong đoạn mã trên, hàm myFunction
nhận vào hai số nguyên và trả về hai số nguyên. Trong hàm main
, khi gọi myFunction
, chúng ta truyền hai tham số 66 và 77 vào hàm. Khi biên dịch đoạn mã trên bằng lệnh go tool compile -S -N -l main.go
, chúng ta sẽ có mã hợp ngữ như sau:
Nếu không sử dụng tham số -N -l khi biên dịch, trình biên dịch sẽ tối ưu mã hợp ngữ và kết quả biên dịch sẽ khác đi đáng kể.
Dựa vào mã hợp ngữ được tạo ra từ hàm main
, chúng ta có thể phân tích ngăn xếp trước khi gọi hàm myFunction
:
Hàm main đã cấp phát tổng cộng 40 byte cho không gian ngăn xếp bằng lệnh SUBQ $40, SP
:
| Không gian | Kích thước | Mục đích | |
| ------------- | ---------- | --------------------------------------- |
| SP+32 ~ BP | 8 byte | Con trỏ cơ sở của ngăn xếp của hàm main | |
| SP+16 ~ SP+32 | 16 byte | Hai giá trị trả về của hàm myFunction |
| SP ~ SP+16 | 16 byte | Hai tham số của hàm myFunction |
Thứ tự đẩy các tham số vào ngăn xếp của myFunction
giống như trong ngôn ngữ C, từ phải sang trái. Ví dụ, tham số đầu tiên 66 được lưu trữ trong không gian SP ~ SP+8 trên đỉnh ngăn xếp, và tham số thứ hai được lưu trữ trong không gian SP+8 ~ SP+16.
Sau khi chuẩn bị xong các tham số của hàm, lệnh CALL "".myFunction(SB)
được gọi, lệnh này sẽ đưa địa chỉ trở về của hàm main
vào ngăn xếp, sau đó thay đổi con trỏ ngăn xếp hiện tại SP và thực hiện các lệnh assembly của myFunction
:
Từ mã hợp ngữ trên, chúng ta có thể thấy rằng trong quá trình thực thi, hàm hiện tại đầu tiên sẽ đặt địa chỉ hai giá trị trả về trong hàm main
thành giá trị mặc định của kiểu int
là 0. Sau đó, nó sẽ lấy các tham số từ ngăn xếp dựa trên vị trí tương đối và thực hiện các phép cộng và trừ, sau đó lưu giá trị trở lại ngăn xếp. Trong quá trình này, dữ liệu trên ngăn xếp sẽ như sau:
Sau khi hàm myFunction
trả về, hàm main
sẽ khôi phục con trỏ cơ sở của ngăn xếp và giải phóng 40 byte không gian ngăn xếp không còn sử dụng bằng các lệnh sau:
Dựa vào phân tích mã hợp ngữ sau khi biên dịch Go, chúng ta nhận thấy rằng Go sử dụng ngăn xếp để truyền tham số và nhận giá trị trả về. Do đó, chỉ cần cấp phát thêm một số lượng bộ nhớ trên ngăn xếp là có thể trả về nhiều giá trị.
So sánh
C và Go là hai ngôn ngữ khác nhau trong cách thiết kế quy ước gọi hàm. Trong C, cả thanh ghi và ngăn xếp được sử dụng để truyền tham số, và thanh ghi eax được sử dụng để truyền giá trị trả về. Trong khi đó, Go sử dụng ngăn xếp để truyền tham số và giá trị trả về. Chúng ta có thể so sánh các ưu điểm và nhược điểm của hai cách thiết kế này:
Cách thiết kế của C giảm thiểu chi phí thêm khi gọi hàm, nhưng cũng làm tăng độ phức tạp của việc triển khai.
- Chi phí truy cập ngăn xếp cao hơn so với truy cập thanh ghi, khoảng vài chục lần.
- Cần xử lý riêng trường hợp có quá nhiều tham số cho hàm.
Cách thiết kế của Go giảm độ phức tạp của việc triển khai và hỗ trợ trả về nhiều giá trị.
- Không cần xem xét cách truyền tham số khi có quá nhiều tham số vượt quá số lượng thanh ghi.
- Không cần xem xét sự khác biệt về thanh ghi trên các kiến trúc khác nhau.
- Cần cấp phát không gian bộ nhớ trên ngăn xếp cho tham số và giá trị trả về.
Cách thiết kế của Go sử dụng ngăn xếp để truyền tham số và giá trị trả về là một lựa chọn sau khi xem xét toàn diện, điều này có nghĩa là trình biên dịch sẽ đơn giản hơn và dễ bảo trì hơn.
Truyền tham số
Ngoài quy ước gọi hàm, một câu hỏi thú vị khác trong Go là liệu tham số được truyền bằng giá trị hay tham chiếu. Trước tiên, hãy nhắc lại sự khác biệt giữa truyền giá trị và truyền tham chiếu:
- Truyền giá trị (pass by value): Khi gọi hàm, tham số sẽ được sao chép, bên gọi và bên được gọi sẽ có hai bản sao dữ liệu không liên quan nhau
- Truyền tham chiếu (pass by reference): Khi gọi hàm, con trỏ của tham số sẽ được truyền, bên gọi và bên được gọi sẽ có cùng một dữ liệu, bất kỳ thay đổi nào từ một bên cũng sẽ ảnh hưởng đến bên kia.
Các ngôn ngữ khác nhau sẽ chọn cách truyền tham số khác nhau, và Go đã chọn cách truyền giá trị, bất kể truyền kiểu cơ bản, mảng hay con trỏ, tất cả đều được sao chép khi truyền. Phần còn lại của nội dung sẽ xác minh tính chính xác của phát hiện này.
Kiểu số và mảng
Chúng ta hãy xem cách Go truyền các kiểu cơ bản và mảng. Hàm myFunction
dưới đây nhận hai tham số, biến số nguyên i
và mảng arr
, hàm này sẽ in ra địa chỉ của hai tham số được truyền vào, và hàm main
cũng sẽ in ra địa chỉ của hai tham số trước và sau khi gọi hàm myFunction
:
Khi chạy mã này, chúng ta sẽ thấy rằng địa chỉ của các tham số trong hàm main
và hàm myFunction
là hoàn toàn khác nhau.
Tuy nhiên, từ quan điểm của hàm main
, địa chỉ của biến số nguyên i
và mảng arr
không thay đổi trước và sau khi gọi hàm myFunction
. Vậy nếu chúng ta thay đổi các tham số trong hàm myFunction
, liệu nó có ảnh hưởng đến biến trong hàm main không? Hãy cập nhật hàm myFunction
và chạy lại đoạn mã này:
Ta có thể thấy rằng việc thay đổi các tham số trong hàm myFunction
chỉ ảnh hưởng đến hàm đó và không ảnh hưởng đến hàm gọi main
, vì vậy chúng ta có thể kết luận: Go truyền giá trị cho kiểu số và mảng, có nghĩa là khi gọi hàm, nội dung sẽ được sao chép. Cần lưu ý rằng nếu kích thước mảng rất lớn, cách truyền giá trị này sẽ ảnh hưởng đến hiệu suất.
Cấu trúc và con trỏ
Tiếp theo, chúng ta sẽ tiếp tục phân tích hai kiểu phổ biến khác trong ngôn ngữ Go - cấu trúc và con trỏ. Đoạn mã dưới đây định nghĩa một cấu trúc MyStruct
và một phương thức myFunction
nhận hai tham số:
Từ kết quả chạy trên, chúng ta có thể rút ra các kết luận sau:
- Khi truyền cấu trúc: toàn bộ nội dung của cấu trúc sẽ được sao chép.
- Khi truyền con trỏ cấu trúc: con trỏ cấu trúc sẽ được sao chép.
Thay đổi con trỏ cấu trúc là thay đổi cấu trúc mà con trỏ đang trỏ tới, b.i
có thể hiểu là (*b).i
, tức là chúng ta trước tiên lấy cấu trúc mà con trỏ b đang trỏ tới, sau đó thay đổi thành viên của cấu trúc. Chúng ta sẽ sửa đổi đoạn mã trên một chút và phân tích cấu trúc trong bộ nhớ của ngôn ngữ Go:
Trong đoạn mã này, chúng ta sửa đổi biến thành viên của cấu trúc thông qua con trỏ. Cấu trúc trong bộ nhớ là một vùng liên tục, con trỏ đến cấu trúc cũng trỏ đến địa chỉ bắt đầu của cấu trúc đó. Nếu chúng ta thay đổi con trỏ MyStruct
thành một kiểu int
, thì truy cập vào con trỏ mới sẽ trả về biến kiểu số nguyên i
, và di chuyển con trỏ 8 byte sau đó sẽ truy cập vào biến thành viên j
tiếp theo.
Nếu chúng ta đơn giản hóa mã thành đoạn mã sau và biên dịch bằng công cụ go tool compile
, chúng ta sẽ nhận được kết quả như sau:
Trong đoạn mã hợp ngữ này, chúng ta nhận thấy khi tham số là con trỏ, sử dụng lệnh MOVQ "".ms+8(SP), AX
để sao chép tham chiếu, sau đó trả về con trỏ đã sao chép như giá trị trả về cho bên gọi.
Vì vậy, khi chúng ta truyền con trỏ vào một hàm nào đó, bên trong hàm sẽ sao chép con trỏ đó, tức là sẽ có hai con trỏ cùng trỏ đến cùng một vùng nhớ ban đầu, vì vậy trong Go, truyền con trỏ cũng là truyền giá trị.
Truyền giá trị
Sau khi xác nhận các cấu trúc dữ liệu phổ biến trong Go, chúng ta có thể suy luận rằng Go sử dụng phương pháp truyền giá trị khi truyền tham số, bên nhận tham số sẽ sao chép các tham số này. Sau khi hiểu điều này, khi truyền mảng hoặc cấu trúc dữ liệu chiếm nhiều bộ nhớ, chúng ta nên sử dụng con trỏ làm kiểu tham số để tránh sao chép dữ liệu và ảnh hưởng đến hiệu suất.
Tóm tắt
Trong phần này, chúng ta đã phân tích chi tiết về quy ước gọi hàm trong ngôn ngữ Go, bao gồm quá trình và nguyên lý truyền tham số và giá trị trả về. Go sử dụng ngăn xếp để truyền tham số và giá trị trả về của hàm, trước khi gọi hàm, nó sẽ cấp phát không gian bộ nhớ phù hợp cho giá trị trả về trên ngăn xếp, sau đó tham số sẽ được đẩy vào ngăn xếp từ phải sang trái theo thứ tự và được sao chép. Giá trị trả về sẽ được lưu trữ trên không gian ngăn xếp đã được cấp phát trước đó. Chúng ta có thể tóm tắt một số quy tắc sau:
- Tham số được truyền qua ngăn xếp, thứ tự đẩy vào từ phải sang trái, và tính toán tham số từ trái sang phải.
- Giá trị trả về của hàm được truyền qua ngăn xếp và được bên gọi trước đó cấp phát không gian bộ nhớ bởi bên gọi.
- Khi gọi hàm, luôn truyền giá trị, và bên nhận sẽ sao chép tham số trước khi tính toán.