Mặc dù không phổ biến trong hầu hết các ứng dụng và dịch vụ, nhưng nhiều framework phụ thuộc vào cơ chế phản xạ của ngôn ngữ Go để đơn giản hóa mã nguồn. Vì ngữ pháp của ngôn ngữ Go rất ít và thiết kế đơn giản, nên nó không có khả năng diễn đạt mạnh mẽ, nhưng gói reflect
của ngôn ngữ Go có thể khắc phục một số hạn chế của nó về cú pháp.
reflect
thực hiện khả năng phản chiếu trong run time, cho phép chương trình làm việc với các đối tượng khác nhau. Gói reflect
chứa hai cặp hàm và kiểu rất quan trọng, hai hàm đó là:
reflect.TypeOf
để lấy thông tin về kiểu dữ liệureflect.ValueOf
để lấy biểu diễn run time của dữ liệu
Hai kiểu dữ liệu là reflect.Type
và reflect.Value
, chúng tương ứng một-một với các hàm:
Kiểu reflect.Type
là một giao diện được định nghĩa trong gói reflect
, chúng ta có thể sử dụng hàm reflect.TypeOf
để lấy kiểu của bất kỳ biến nào. Giao diện reflect.Type
định nghĩa một số phương thức thú vị, MethodByName
có thể lấy tham chiếu của phương thức tương ứng với kiểu hiện tại, Implements
có thể kiểm tra xem kiểu hiện tại có triển khai một giao diện nào đó hay không:
Kiểu reflect.Value
trong gói reflect khác với kiểu reflect.Type
, nó được khai báo là một cấu trúc. Cấu trúc này không có trường được công khai, nhưng cung cấp các phương thức để lấy hoặc ghi dữ liệu:
Tất cả các phương thức trong gói reflect
đều được thiết kế xung quanh hai kiểu reflect.Type và reflect.Value. Chúng ta có thể chuyển đổi một biến thông thường thành reflect.Type và reflect.Value được cung cấp trong gói phản chiếu bằng cách sử dụng reflect.TypeOf
và reflect.ValueOf
, sau đó chúng ta có thể sử dụng các phương thức trong gói phản chiếu để thực hiện các thao tác phức tạp trên chúng.
Ba nguyên tắc quan trọng của phản chiếu
Phản chiếu trong run time là một cách để chương trình kiểm tra cấu trúc của nó trong quá trình chạy. Tính linh hoạt của phản chiếu là một con dao hai lưỡi, phản chiếu như một phương pháp lập trình meta có thể giảm thiểu mã lặp lại, nhưng sử dụng quá nhiều phản chiếu có thể làm cho logic của chương trình trở nên khó hiểu và chậm. Trong phần này, chúng ta sẽ giới thiệu ba nguyên tắc quan trọng của phản chiếu trong ngôn ngữ Go, bao gồm:
- Có thể phản chiếu từ biến
interface{}
thành đối tượng phản chiếu. - Có thể lấy được biến
interface{}
từ đối tượng phản chiếu - Để thay đổi đối tượng phản chiếu, giá trị của nó phải có thể được thiết lập.
Nguyên tắc thứ nhất
Nguyên tắc phản chiếu đầu tiên là chúng ta có thể chuyển đổi biến interface{}
của ngôn ngữ Go thành đối tượng phản chiếu. Rất nhiều người đọc có thể bị nhầm lẫn với nguyên tắc này - tại sao chúng ta chuyển đổi từ biến interface{}
thành đối tượng phản chiếu? Khi chúng ta thực hiện reflect.ValueOf(1)
, mặc dù có vẻ như chúng ta đang lấy loại phản chiếu tương ứng với kiểu dữ liệu cơ bản int
, nhưng do các phương thức reflect.TypeOf
và reflect.ValueOf
có tham số là kiểu interface{}
, nên trong quá trình thực thi phương thức, chúng ta thực hiện chuyển đổi kiểu.
Vì các cuộc gọi hàm trong ngôn ngữ Go đều truyền giá trị, biến cơ bản int
sẽ được chuyển đổi thành kiểu interface{}
, đó cũng là lý do tại sao nguyên tắc đầu tiên là từ interface{}
đến đối tượng phản chiếu.
Các hàm reflect.TypeOf
và reflect.ValueOf
đã được đề cập ở trên có thể thực hiện chuyển đổi này, nếu chúng ta coi kiểu của ngôn ngữ Go và kiểu phản chiếu là hai thế giới khác nhau, thì hai hàm này chính là cây cầu kết nối giữa hai thế giới này.
Chúng ta có thể giới thiệu chức năng của chúng thông qua ví dụ sau, reflect.TypeOf
lấy kiểu của biến author, reflect.ValueOf
lấy giá trị của biến draven. Nếu chúng ta biết kiểu và giá trị của một biến, điều đó có nghĩa là chúng ta biết tất cả thông tin về biến đó.
Sau khi có kiểu biến, chúng ta có thể sử dụng phương thức Method
để lấy các phương thức được triển khai bởi kiểu, sử dụng Field
để lấy tất cả các trường của kiểu. Đối với các kiểu khác nhau, chúng ta cũng có thể gọi các phương thức khác nhau để lấy thông tin liên quan:
- Cấu trúc: Lấy số lượng trường và lấy trường
StructField
thông qua chỉ số và tên trường - Bảng băm: Lấy kiểu Key của bảng băm
- Hàm hoặc phương thức: Lấy kiểu tham số đầu vào và giá trị trả về
- …
Tóm lại, sử dụng reflect.TypeOf
và reflect.ValueOf
có thể lấy được đối tượng phản chiếu tương ứng với biến trong ngôn ngữ Go. Khi đã có đối tượng phản chiếu, chúng ta có thể có được dữ liệu và thao tác liên quan đến kiểu hiện tại và thực hiện các phương thức được lấy trong run time.
Nguyên tắc thứ hai
Nguyên tắc phản chiếu thứ hai là chúng ta có thể lấy biến interface{}
từ đối tượng phản chiếu. Vì chúng ta có thể chuyển đổi biến kiểu interface{}
thành đối tượng phản chiếu, nên chắc chắn cần có các phương pháp khác để chuyển đổi đối tượng phản chiếu thành biến kiểu interface{}
, reflect.Value.Interface
trong gói reflect
có thể hoàn thành công việc này:
Tuy nhiên, việc gọi phương thức reflect.Value.Interface
chỉ có thể nhận được biến kiểu interface{}
, nếu muốn khôi phục nó về trạng thái ban đầu, cần thực hiện chuyển đổi kiểu rõ ràng như sau:
Quá trình từ đối tượng phản chiếu thành giá trị interface là quá trình phản chiếu của giá trị interface thành đối tượng phản chiếu, cả hai quá trình đều cần trải qua hai lần chuyển đổi:
- Từ giá trị interface thành đối tượng phản chiếu:
- Chuyển đổi từ kiểu cơ bản thành kiểu interface
- Chuyển đổi từ kiểu interface thành đối tượng phản chiếu
- Từ đối tượng phản chiếu thành giá trị interface:
- Chuyển đổi đối tượng phản chiếu thành kiểu interface
- Chuyển đổi kiểu rõ ràng thành kiểu gốc
Tất nhiên, không phải tất cả các biến đều cần trải qua quá trình chuyển đổi kiểu này. Nếu biến chính nó đã có kiểu interface{}
, thì không cần chuyển đổi kiểu, vì quá trình chuyển đổi kiểu này thường là ngầm định, vì vậy chúng ta không cần quan tâm đến nó, chỉ khi chúng ta cần chuyển đổi đối tượng phản chiếu trở lại kiểu cơ bản mới cần thực hiện chuyển đổi rõ ràng.
Nguyên tắc thứ ba
Nguyên tắc cuối cùng của phản chiếu trong ngôn ngữ Go liên quan đến khả năng thay đổi giá trị. Nếu chúng ta muốn cập nhật một reflect.Value
, thì giá trị mà nó giữ phải có thể được cập nhật, giả sử chúng ta có đoạn mã sau:
Chạy đoạn mã trên sẽ làm cho chương trình gặp lỗi và báo lỗi “reflect: reflect.flag.mustBeAssignable using unaddressable value”, nếu suy nghĩ kỹ, chúng ta có thể nhận ra lý do lỗi: vì cuộc gọi hàm trong ngôn ngữ Go truyền giá trị, đối tượng phản chiếu mà chúng ta nhận được không có liên quan gì đến biến ban đầu, vì vậy việc thay đổi trực tiếp đối tượng phản chiếu không thể thay đổi biến gốc, chương trình sẽ gặp lỗi để tránh sai sót.
Để thay đổi biến gốc, chúng ta chỉ có thể sử dụng phương pháp sau:
- Gọi
reflect.ValueOf
để lấy con trỏ biến - Gọi
reflect.Value.Elem
để lấy biến mà con trỏ trỏ đến - Gọi
reflect.Value.SetInt
để cập nhật giá trị của biến:
Vì cuộc gọi hàm trong ngôn ngữ Go truyền giá trị, chúng ta chỉ có thể thay đổi biến gốc theo cách gián tiếp: trước tiên lấy reflect.Value
tương ứng với con trỏ, sau đó sử dụng phương thức reflect.Value.Elem
để lấy biến có thể được thiết lập, chúng ta có thể hiểu quá trình này thông qua đoạn mã sau:
Nếu không thể trực tiếp thay đổi giá trị của biến i
, chúng ta chỉ có thể lấy địa chỉ của biến i
và sử dụng *v
để thay đổi số nguyên được lưu trữ tại địa chỉ đó.
Các kiểu và giá trị
Trong ngôn ngữ Go, kiểu interface{}
được biểu diễn bằng cấu trúc reflect.emptyInterface
trong bên trong ngôn ngữ, trong đó trường rtype
được sử dụng để đại diện cho kiểu của biến và trường word trỏ đến dữ liệu được đóng gói bên trong:
Hàm reflect.TypeOf
được sử dụng để lấy kiểu của biến sẽ tự động chuyển đổi biến truyền vào thành kiểu reflect.emptyInterface
và lấy thông tin kiểu được lưu trữ trong đó reflect.rtype
:
reflect.rtype
là một cấu trúc triển khai giao diện reflect.Type
, phương thức reflect.rtype.String
được triển khai để giúp chúng ta lấy tên của kiểu hiện tại.
Cách thức triển khai của reflect.TypeOf
thực sự không phức tạp, nó chỉ chuyển đổi biến interface{}
thành biểu diễn nội bộ reflect.emptyInterface
, sau đó lấy thông tin kiểu tương ứng từ đó.
Hàm reflect.ValueOf
để lấy giá trị interface{}
được triển khai cũng rất đơn giản, trong hàm này chúng ta trước tiên gọi reflect.escapes
để đảm bảo giá trị hiện tại thoát ra khỏi ngăn xếp, sau đó sử dụng reflect.unpackEface
để lấy cấu trúc reflect.Value
từ giao diện:
reflect.unpackEface
sẽ chuyển đổi giao diện truyền vào thành reflect.emptyInterface
, sau đó đóng gói kiểu cụ thể và con trỏ thành cấu trúc reflect.Value
và trả về.
Triển khai của reflect.TypeOf
và reflect.ValueOf
đều rất đơn giản. Chúng ta đã phân tích cách triển khai của hai hàm này, bây giờ cần hiểu công việc mà trình biên dịch thực hiện trước khi gọi hàm:
Từ đoạn mã ngữ hợp ngữ trên, chúng ta có thể thấy rằng đã có sự chuyển đổi kiểu trước khi gọi hàm, các chỉ thị trên chuyển đổi biến kiểu int
thành một giao diện chiếm 16 byte từ autotmp_19+280(SP) ~ autotmp_19+288(SP)
, hai chỉ thị LEAQ
lần lượt lấy địa chỉ của kiểu type.int(SB)
và địa chỉ của biến i
.
Khi chúng ta muốn chuyển đổi một biến thành đối tượng phản chiếu, ngôn ngữ Go sẽ hoàn thành chuyển đổi kiểu trong quá trình biên dịch, chuyển đổi kiểu và giá trị của biến thành interface{}
và chờ sử dụng gói reflect
để lấy thông tin được lưu trữ trong giao diện trong quá trình chạy.
Cập nhật biến
Khi chúng ta muốn cập nhật reflect.Value
, chúng ta cần gọi phương thức reflect.Value.Set
để cập nhật đối tượng phản chiếu. Phương thức này sẽ gọi reflect.flag.mustBeAssignable
và reflect.flag.mustBeExported
để kiểm tra xem đối tượng phản chiếu hiện tại có thể được thiết lập hay không và trường có phải là trường được công khai hay không:
reflect.Value.Set
sẽ gọi reflect.Value.assignTo
và trả về một đối tượng phản chiếu mới, con trỏ của đối tượng phản chiếu trả về này sẽ ghi đè lên biến phản chiếu ban đầu.
reflect.Value.assignTo
sẽ tạo ra một cấu trúc reflect.Value
mới dựa trên kiểu của đối tượng phản chiếu hiện tại và đối tượng phản chiếu được thiết lập:
- Nếu hai đối tượng phản chiếu có thể được trực tiếp thay thế, nó sẽ trả về đối tượng phản chiếu đích trực tiếp.
- Nếu đối tượng phản chiếu hiện tại là một giao diện và đối tượng đích triển khai giao diện đó, nó sẽ đóng gói đối tượng đích thành một giá trị giao diện đơn giản.
Trong quá trình cập nhật biến, con trỏ trong reflect.Value
trả về sẽ ghi đè lên con trỏ trong đối tượng phản chiếu hiện tại để cập nhật giá trị của biến.
Triển khai giao diện
Gói reflect
cung cấp phương thức reflect.rtype.Implements
để xác định xem một loại cụ thể có triển khai một giao diện hay không. Để có được kiểu phản chiếu của một cấu trúc trong ngôn ngữ Go, chúng ta có thể sử dụng phương pháp sau:
Chúng ta sử dụng một ví dụ để giới thiệu cách đánh giá xem một loại cụ thể có triển khai một giao diện nhất định hay không. Giả sử chúng ta cần đánh giá xem đoạn mã sau CustomError
có triển khai giao diện error
trong thư viện chuẩn của ngôn ngữ Go hay không:
Kết quả chạy đoạn mã trên như chúng ta đã giới thiệu ở phần Golang Interface:
CustomError
không triển khai giao diệnerror
.*CustomError
triển khai giao diệnerror
Bất kể kết quả triển khai ở trên như thế nào, hãy cùng phân tích nguyên tắc hoạt động của phương thức reflect.rtype.Implements
:
reflect.rtype.Implements
sẽ kiểm tra xem loại đến có phải là giao diện hay không, nếu không phải là giao diện hoặc giá trị null, nó sẽ gây ra một sự cố và kết thúc chương trình hiện tại. Nếu không có vấn đề với các tham số, phương thức này sẽ gọi riêng reflect.implements
để xác định xem có mối quan hệ triển khai giữa các loại hay không:
Trong trường hợp giao diện không chứa bất kỳ phương thức nào, điều đó có nghĩa là đây là giao diện trống và bất kỳ loại nào cũng sẽ tự động triển khai giao diện và trả về true
tại thời điểm này.
Trong các trường hợp khác, vì các phương thức được lưu trữ theo thứ tự bảng chữ cái, reflect.implements
sử dụng hai chỉ mục để lặp qua các giao diện và phương thức của kiểu i
và j
để xác định xem kiểu có triển khai giao diện hay không. Vì chỉ có một phép so sánh tối đa (số phương thức của kiểu) sẽ được triển khai, độ phức tạp thời gian của quá trình này là O(n).
Lưu ý đây là kĩ thuật two pointer
Việc hiểu các thuật toán rất quan trọng, vui lòng đọc thêm trong phần DSA MOC
Gọi phương thức
Trong một ngôn ngữ tĩnh như Go, việc sử dụng reflect
để thực hiện gọi phương thức trong run time không phải là một việc dễ dàng. Dưới đây là một đoạn mã sử dụng phản chiếu để gọi hàm Add(0, 1)
:
- Sử dụng
reflect.ValueOf
để lấy đối tượng phản chiếu tương ứng với hàmAdd
. - Gọi
reflect.rtype.NumIn
để lấy số lượng tham số đầu vào của hàm. - Sử dụng
reflect.ValueOf
lặp lại để thiết lập từng tham số vào mảngargv
; - Gọi phương
reflect.Value.Call
của đối tượng phản chiếuAdd
và truyền danh sách tham số. - Lấy mảng kết quả và kiểm tra độ dài và kiểu dữ liệu của mảng, sau đó in ra dữ liệu trong mảng.
Sử dụng phản chiếu để gọi phương thức là một quá trình phức tạp. Những gì chỉ cần một dòng mã để thực hiện, bây giờ cần hơn chục dòng mã mới có thể hoàn thành. Tuy nhiên, đây cũng là một trong những chi phí phải trả khi sử dụng tính năng động trong một ngôn ngữ tĩnh.
reflect.Value.Call
là điểm vào để gọi phương thức trong run time. Nó sử dụng hai phương thức MustBe
để xác định xem đối tượng phản chiếu hiện tại có phải là một hàm và có được công khai không. Sau đó, nó gọi phương thức reflect.Value.call
để thực hiện cuộc gọi phương thức. Quá trình thực hiện của phương thức này được chia thành các phần sau:
- Kiểm tra tính hợp lệ của tham số đầu vào và kiểu.
- Đặt các đối tượng phản chiếu của tham số vào ngăn xếp.
- Gọi hàm bằng con trỏ hàm và tham số.
- Lấy giá trị trả về từ ngăn xếp.
Chúng ta sẽ phân tích các quy trình sử dụng reflect để gọi hàm theo thứ tự như trên.
Kiểm tra tham số
Kiểm tra tham số là bước đầu tiên trong quá trình gọi phương thức bằng reflect
. Trong quá trình kiểm tra tham số, chúng ta sẽ lấy con trỏ hàm hiện tại từ đối tượng reflect
bằng unsafe.Pointer
. Nếu con trỏ hàm đó là một phương thức, chúng ta sẽ sử dụng reflect.methodReceiver
để lấy bộ nhận và con trỏ hàm của phương thức.
Phương thức trên cũng kiểm tra số lượng tham số truyền vào và kiểm tra xem kiểu dữ liệu của các tham số có khớp với kiểu dữ liệu trong chữ ký hàm hay không. Bất kỳ không khớp nào sẽ gây ra lỗi và dừng chương trình.
Chuẩn bị tham số
Sau khi đã xác minh các tham số của phương thức hiện tại, chúng ta sẽ chuyển sang giai đoạn chuẩn bị tham số cho cuộc gọi hàm. Như đã giới thiệu trong phần trước về cuộc gọi hàm, tất cả các tham số của hàm hoặc phương thức sẽ được đặt trên ngăn xếp.
- Tính toán bố cục ngăn xếp cho các tham số và giá trị trả về của hàm bằng
reflect.funcLayout
, đó là kích thước mỗi tham số và giá trị trả về. - Nếu hàm hiện tại có giá trị trả về, ta sẽ cấp phát một vùng nhớ
args
cho các tham số và giá trị trả về của hàm. - Nếu hàm hiện tại là một phương thức, chúng ta sao chép bộ nhận của phương thức vào vùng nhớ
args
. - Sao chép các tham số của hàm theo thứ tự vào vùng nhớ
args
.- Tính toán vị trí của từng tham số trong vùng nhớ
args
bằng các thông số trả về từreflect.funcLayout
. - Sao chép các tham số vào vùng nhớ tương ứng.
- Tính toán vị trí của từng tham số trong vùng nhớ
Chuẩn bị tham số là quá trình tính toán kích thước của các tham số và giá trị trả về và sao chép tất cả các tham số vào vị trí tương ứng trong vùng nhớ. Quá trình này sẽ xem xét sự khác biệt do hàm và phương thức, số lượng giá trị trả về và kiểu dữ liệu của các tham số gây ra.
Gọi hàm
Sau khi đã chuẩn bị đầy đủ các tham số cần thiết cho cuộc gọi hàm, chúng ta sẽ thực hiện gọi con trỏ hàm. Chúng ta truyền vào con trỏ hàm, vùng nhớ args, kích thước ngăn xếp và vị trí của giá trị trả về:
Phương thức trên thực tế không tồn tại, nó sẽ được liên kết với hàm reflect.reflectcall
được viết bằng ngôn ngữ hợp ngữ trong quá trình biên dịch. Chúng ta không phân tích cụ thể cài đặt của hàm này ở đây, nhưng bạn đọc quan tâm có thể tự tìm hiểu.
Xử lý giá trị trả về
Sau khi cuộc gọi hàm kết thúc, chúng ta bắt đầu xử lý giá trị trả về của hàm.
- Nếu hàm không có giá trị trả về, chúng ta sẽ xóa toàn bộ nội dung của vùng nhớ
args
để giải phóng bộ nhớ. - Nếu hàm hiện tại có giá trị trả về.
- Xóa các vùng nhớ liên quan đến các tham số đầu vào trong
args
. - Tạo một mảng
ret
có độ dàinout
để lưu trữ các giá trị trả về được tạo thành từ các đối tượng reflect. - Lấy kiểu và kích thước của giá trị trả về từ đối tượng hàm, sau đó chuyển đổi dữ liệu trong vùng nhớ args thành kiểu
reflect.Value
và lưu trữ vào mảng ret.
- Xóa các vùng nhớ liên quan đến các tham số đầu vào trong
Mảng ret
được tạo thành từ các đối tượng reflect.Value
sẽ được trả về cho bộ gọi. Đến đây, quá trình sử dụng reflect
để gọi hàm kết thúc.
Tóm tắt
Gói reflect
trong ngôn ngữ Go cung cấp cho chúng ta nhiều khả năng, bao gồm cách sử dụng phản chiếu để thay đổi biến động, kiểm tra xem một kiểu có thực hiện một số giao diện nào đó hay không và gọi phương thức động, và nhiều chức năng khác. Bằng cách phân tích nguyên lý của các phương thức trong gói reflect
, chúng ta có thể hiểu được những hiện tượng trước đây có vẻ kỳ lạ và gây khó hiểu.