Blog Go
Xây dựng kiểu và phát hiện chu trình
Kiểu tĩnh của Go là một phần quan trọng lý giải tại sao Go phù hợp cho các hệ thống production cần sự mạnh mẽ và đáng tin cậy. Khi một package Go được biên dịch, nó trước tiên được phân tích cú pháp, tức là mã nguồn Go trong package đó được chuyển đổi thành cây cú pháp trừu tượng (AST). AST này sau đó được truyền vào trình kiểm tra kiểu (type checker) của Go.
Trong bài viết này, chúng ta sẽ đi sâu vào một phần của trình kiểm tra kiểu mà chúng tôi đã cải thiện đáng kể trong Go 1.26. Điều này thay đổi gì từ góc nhìn của người dùng Go? Trừ khi bạn thích các định nghĩa kiểu phức tạp, không có thay đổi nào có thể quan sát được ở đây. Cải tiến này nhằm giảm các trường hợp đặc biệt, chuẩn bị cho các cải tiến trong tương lai của Go. Ngoài ra, đây là một góc nhìn thú vị về điều mà có vẻ khá bình thường với các lập trình viên Go, nhưng lại chứa đựng những sự tinh tế thực sự bên trong.
Nhưng trước tiên, kiểm tra kiểu là gì? Đây là bước trong trình biên dịch Go loại bỏ toàn bộ các lớp lỗi tại thời gian biên dịch. Cụ thể, trình kiểm tra kiểu Go xác minh rằng:
- Các kiểu xuất hiện trong AST là hợp lệ (ví dụ, kiểu khóa của một map phải
comparable). - Các phép toán liên quan đến các kiểu đó (hoặc giá trị của chúng) là hợp lệ (ví dụ,
không thể cộng một
intvà mộtstring).
Để thực hiện điều này, trình kiểm tra kiểu xây dựng một biểu diễn nội tại cho mỗi kiểu nó gặp trong khi duyệt AST, một quá trình được gọi không chính thức là xây dựng kiểu (type construction).
Như chúng ta sẽ thấy, mặc dù Go được biết đến với hệ thống kiểu đơn giản, xây dựng kiểu có thể phức tạp đến mức đánh lừa được trong một số góc của ngôn ngữ.
Xây dựng kiểu
Hãy bắt đầu bằng cách xem xét một cặp khai báo kiểu đơn giản:
type T []U
type U *int
Khi trình kiểm tra kiểu được gọi, nó đầu tiên gặp khai báo kiểu cho
T. Ở đây, AST ghi lại một định nghĩa kiểu của tên kiểu T và một
biểu thức kiểu []U. T là một kiểu đã định nghĩa; để biểu diễn
cấu trúc dữ liệu thực tế mà trình kiểm tra kiểu sử dụng khi xây dựng các kiểu đã định nghĩa,
chúng ta sẽ sử dụng một struct Defined.
Struct Defined chứa một con trỏ đến kiểu của biểu thức kiểu bên phải tên kiểu.
Trường underlying này có liên quan đến việc xác định
kiểu cơ sở của kiểu. Để minh họa trạng thái
của trình kiểm tra kiểu, hãy xem cách duyệt AST điền vào các cấu trúc
dữ liệu, bắt đầu với:
Tại thời điểm này, T đang trong quá trình xây dựng, được chỉ thị bởi màu vàng. Vì
chúng ta chưa đánh giá biểu thức kiểu []U (nó vẫn màu đen), underlying
trỏ đến nil, được chỉ thị bởi một mũi tên rỗng.
Khi chúng ta đánh giá []U, trình kiểm tra kiểu xây dựng một struct Slice, là
cấu trúc dữ liệu nội tại dùng để biểu diễn các kiểu slice. Tương tự như Defined,
nó chứa một con trỏ đến kiểu phần tử cho slice. Chúng ta chưa biết tên
U chỉ đến cái gì, mặc dù chúng ta kỳ vọng nó chỉ đến một kiểu. Vì vậy, lại một lần nữa,
con trỏ này là nil. Chúng ta kết thúc với:
Đến đây bạn có thể đã nắm được ý chính rồi, vì vậy chúng ta sẽ tăng tốc một chút.
Để chuyển đổi tên kiểu U thành một kiểu, trước tiên chúng ta xác định vị trí khai báo của nó.
Nhận thấy nó đại diện cho một kiểu đã định nghĩa khác, chúng ta xây dựng một Defined riêng
cho U tương ứng. Kiểm tra bên phải của U, chúng ta thấy biểu thức kiểu
*int, được đánh giá thành một struct Pointer, với kiểu cơ sở của con trỏ là biểu thức kiểu int.
Khi chúng ta đánh giá int, có điều gì đó đặc biệt xảy ra: chúng ta nhận lại một kiểu
được khai báo trước. Các kiểu được khai báo trước được xây dựng trước khi trình kiểm tra kiểu
bắt đầu duyệt AST. Vì kiểu cho int đã được xây dựng, không có gì cho chúng ta làm
ngoài việc trỏ đến kiểu đó.
Bây giờ chúng ta có:
Lưu ý rằng kiểu Pointer là hoàn chỉnh (complete) tại thời điểm này, được chỉ thị bởi màu
xanh lá. Tính hoàn chỉnh có nghĩa là cấu trúc dữ liệu nội tại của kiểu có tất cả các
trường được điền và các kiểu được trỏ đến bởi các trường đó là hoàn chỉnh.
Tính hoàn chỉnh là một thuộc tính quan trọng của kiểu vì nó đảm bảo rằng
việc truy cập vào nội bộ (deconstruction) của kiểu đó là hợp lệ: chúng ta có tất cả
thông tin mô tả kiểu.
Trong hình trên, struct Pointer chỉ chứa trường base, trỏ đến int.
Vì int không có trường nào để điền, nó “rỗng hoàn chỉnh” (vacuously complete),
làm cho kiểu cho *int là hoàn chỉnh.
Từ đây, trình kiểm tra kiểu bắt đầu tháo gỡ stack. Vì kiểu cho
*int là hoàn chỉnh, chúng ta có thể hoàn thành kiểu cho U, nghĩa là chúng ta có thể hoàn thành
kiểu cho []U, và tương tự cho T. Khi quá trình này kết thúc, chúng ta kết thúc với
chỉ các kiểu hoàn chỉnh, như được hiển thị bên dưới:
Đánh số ở trên cho thấy thứ tự các kiểu được hoàn thành (sau Pointer). Lưu ý rằng
kiểu ở dưới cùng được hoàn thành trước. Xây dựng kiểu về cơ bản là một quá trình ưu tiên chiều sâu,
vì việc hoàn thành một kiểu đòi hỏi các phụ thuộc của nó phải được hoàn thành trước.
Các kiểu đệ quy
Với ví dụ đơn giản này đã qua, hãy thêm một chút phức tạp. Hệ thống kiểu của Go cũng cho phép chúng ta diễn tả các kiểu đệ quy. Một ví dụ điển hình là:
type Node struct {
next *Node
}
Nếu chúng ta xem xét lại ví dụ trên, chúng ta có thể thêm một chút đệ quy bằng cách
thay *int bằng *T như sau:
type T []U
type U *T
Bây giờ hãy theo dõi: hãy bắt đầu lại với T, nhưng bỏ qua một số bước để minh họa
hiệu quả của thay đổi này. Như người ta có thể nghi ngờ từ ví dụ trước,
trình kiểm tra kiểu sẽ tiếp cận việc đánh giá *T với trạng thái dưới đây:
Câu hỏi là phải làm gì với kiểu cơ sở cho *T. Chúng ta có ý tưởng về
T là gì (một Defined), nhưng nó hiện đang được xây dựng (trường underlying của nó
vẫn là nil).
Chúng ta đơn giản trỏ kiểu cơ sở cho *T đến T, mặc dù T chưa hoàn chỉnh:
Chúng ta làm điều này với giả định rằng T sẽ hoàn chỉnh khi nó kết thúc quá trình xây dựng
trong tương lai (bằng cách trỏ đến một kiểu hoàn chỉnh). Khi điều đó xảy ra, base sẽ
trỏ đến một kiểu hoàn chỉnh, do đó làm cho *T trở nên hoàn chỉnh.
Trong thời gian đó, chúng ta sẽ bắt đầu đi ngược lại stack:
Khi chúng ta quay lại đỉnh và kết thúc việc xây dựng T, “vòng lặp” của các kiểu
sẽ đóng lại, hoàn thành mỗi kiểu trong vòng lặp đồng thời:
Trước khi chúng ta xem xét các kiểu đệ quy, việc đánh giá một biểu thức kiểu luôn trả về một kiểu hoàn chỉnh. Đó là thuộc tính tiện lợi vì nó có nghĩa là trình kiểm tra kiểu có thể luôn phân tách (look inside) một kiểu được trả về từ đánh giá.
Nhưng trong ví dụ trên, đánh giá T trả về một kiểu chưa hoàn chỉnh,
có nghĩa là việc phân tách T là không hợp lệ cho đến khi nó hoàn chỉnh. Nói chung
nói, các kiểu đệ quy có nghĩa là trình kiểm tra kiểu không còn có thể giả định rằng
các kiểu được trả về từ đánh giá sẽ là hoàn chỉnh.
Tuy nhiên, kiểm tra kiểu bao gồm nhiều kiểm tra đòi hỏi phải phân tách một kiểu.
Một ví dụ điển hình là xác nhận rằng khóa của map là comparable, đòi hỏi
kiểm tra trường underlying. Làm sao chúng ta có thể tương tác an toàn với các kiểu
chưa hoàn chỉnh như T?
Nhớ lại rằng tính hoàn chỉnh của kiểu là điều kiện tiên quyết để phân tách một kiểu. Trong trường hợp này, xây dựng kiểu không bao giờ phân tách một kiểu, nó chỉ tham chiếu đến các kiểu. Nói cách khác, tính hoàn chỉnh của kiểu không cản trở xây dựng kiểu ở đây.
Vì xây dựng kiểu không bị cản trở, trình kiểm tra kiểu có thể đơn giản trì hoãn các kiểm tra như vậy cho đến khi kết thúc quá trình kiểm tra kiểu, khi tất cả các kiểu đều hoàn chỉnh (lưu ý rằng bản thân các kiểm tra cũng không cản trở xây dựng kiểu). Nếu một kiểu tiết lộ một lỗi kiểu, không có gì khác biệt khi lỗi đó được báo cáo trong quá trình kiểm tra kiểu, chỉ cần nó được báo cáo cuối cùng.
Với kiến thức này, hãy xem xét một ví dụ phức tạp hơn liên quan đến các giá trị của kiểu chưa hoàn chỉnh.
Kiểu đệ quy và giá trị
Hãy dừng lại một chút và xem xét
kiểu mảng của Go. Quan trọng là, kiểu mảng có kích thước,
đây là hằng số là một phần của kiểu. Một số
phép toán, như các hàm tích hợp unsafe.Sizeof và len có thể trả về
các hằng số khi được áp dụng cho một số giá trị nhất định hoặc
biểu thức, có nghĩa là chúng có thể xuất hiện như
kích thước mảng. Quan trọng là, các giá trị được truyền cho các hàm đó có thể thuộc bất kỳ kiểu nào,
ngay cả một kiểu chưa hoàn chỉnh. Chúng ta gọi những giá trị này là giá trị chưa hoàn chỉnh.
Hãy xem xét ví dụ này:
type T [unsafe.Sizeof(T{})]int
Theo cách tương tự như trước, chúng ta sẽ đạt đến trạng thái như bên dưới:
Để xây dựng Array, chúng ta phải tính kích thước của nó. Từ biểu thức giá trị
unsafe.Sizeof(T{}), đó là kích thước của T. Đối với các kiểu mảng (như T),
việc tính kích thước đòi hỏi phân tách: chúng ta cần nhìn vào bên trong kiểu để
xác định độ dài của mảng và kích thước của từng phần tử.
Nói cách khác, xây dựng kiểu cho Array thực sự phân tách T,
có nghĩa là Array không thể kết thúc quá trình xây dựng (chứ chưa nói đến hoàn chỉnh) trước khi
T hoàn chỉnh. Mẹo “vòng lặp” mà chúng ta đã sử dụng trước đó, trong đó một vòng lặp các kiểu
đồng thời hoàn chỉnh khi kiểu bắt đầu vòng lặp kết thúc quá trình xây dựng, không hoạt động ở đây.
Điều này để chúng ta trong một tình huống khó:
Tkhông thể hoàn chỉnh cho đến khiArrayhoàn chỉnh.Arraykhông thể hoàn chỉnh cho đến khiThoàn chỉnh.- Chúng không thể hoàn chỉnh đồng thời (không giống như trước).
Rõ ràng, điều này là không thể thỏa mãn. Trình kiểm tra kiểu phải làm gì?
Phát hiện chu trình
Về cơ bản, mã như thế này là không hợp lệ vì kích thước của T không thể
được xác định mà không biết kích thước của T, bất kể trình kiểm tra kiểu
hoạt động như thế nào. Trường hợp cụ thể này, định nghĩa kích thước theo chu trình, là một phần của
một lớp lỗi gọi là lỗi chu trình, thường liên quan đến định nghĩa chu trình của
các cấu trúc Go. Như một ví dụ khác, xem xét type T T, cũng thuộc
lớp này, nhưng vì lý do khác. Quá trình tìm và báo cáo lỗi chu trình
trong quá trình kiểm tra kiểu được gọi là phát hiện chu trình.
Bây giờ, phát hiện chu trình hoạt động như thế nào cho type T [unsafe.Sizeof(T{})]int?
Để trả lời điều này, hãy xem xét T{} bên trong. Vì T{} là một biểu thức composite literal,
trình kiểm tra kiểu biết rằng giá trị kết quả của nó có kiểu T.
Vì T chưa hoàn chỉnh, chúng ta gọi giá trị T{} là giá trị chưa hoàn chỉnh.
Chúng ta phải thận trọng, vì thao tác trên một giá trị chưa hoàn chỉnh chỉ hợp lệ nếu
nó không phân tách kiểu của giá trị. Ví dụ, type T [unsafe.Sizeof(new(T))]int
là hợp lệ, vì giá trị new(T) (kiểu *T) không bao giờ bị phân tách,
tất cả các con trỏ đều có cùng kích thước. Để nhấn mạnh lại, việc xác định kích thước
của một giá trị chưa hoàn chỉnh kiểu *T là hợp lệ, nhưng không phải kiểu T.
Điều này bởi vì “tính con trỏ” của *T cung cấp đủ thông tin kiểu cho
unsafe.Sizeof, trong khi chỉ T thì không. Trên thực tế, không bao giờ hợp lệ để thao tác
trên một giá trị chưa hoàn chỉnh mà kiểu của nó là một kiểu đã định nghĩa, vì chỉ một tên kiểu
không truyền tải bất kỳ thông tin kiểu (cơ sở) nào cả.
Nơi thực hiện phát hiện
Cho đến nay chúng ta đã tập trung vào unsafe.Sizeof trực tiếp thao tác trên
các giá trị có thể chưa hoàn chỉnh. Trong type T [unsafe.Sizeof(T{})]int, lời gọi đến unsafe.Sizeof
chỉ là “gốc” của biểu thức độ dài mảng. Chúng ta có thể dễ dàng hình dung
giá trị chưa hoàn chỉnh T{} như một toán hạng trong biểu thức giá trị nào đó khác.
Ví dụ, nó có thể được truyền cho một hàm (tức là
type T [unsafe.Sizeof(f(T{}))]int), được slice (tức là
type T [unsafe.Sizeof(T{}[:])]int), được lập chỉ mục (tức là
type T [unsafe.Sizeof(T{}[0])]int), v.v. Tất cả những cái này không hợp lệ vì chúng
đòi hỏi phân tách T. Ví dụ, việc lập chỉ mục T
đòi hỏi kiểm tra kiểu cơ sở của T.
Vì các biểu thức này “tiêu thụ” các giá trị có thể chưa hoàn chỉnh, hãy gọi chúng là
downstream. Có nhiều ví dụ hơn về toán tử downstream, một số trong đó không rõ ràng về mặt cú pháp.
Tương tự, T{} chỉ là một ví dụ về biểu thức “tạo ra” một giá trị có thể chưa hoàn chỉnh,
hãy gọi các loại biểu thức này là upstream:
So sánh, có ít hơn và các biểu thức giá trị rõ ràng hơn về mặt cú pháp có thể dẫn đến các giá trị chưa hoàn chỉnh. Ngoài ra, việc liệt kê các trường hợp này bằng cách kiểm tra định nghĩa cú pháp của Go khá đơn giản. Vì những lý do này, sẽ đơn giản hơn để triển khai logic phát hiện chu trình của chúng ta thông qua các upstream, nơi các giá trị có thể chưa hoàn chỉnh bắt nguồn. Dưới đây là một số ví dụ về chúng:
type T [unsafe.Sizeof(T(42))]int // chuyển đổi
func f() T
type T [unsafe.Sizeof(f())]int // gọi hàm
var i interface{}
type T [unsafe.Sizeof(i.(T))]int // type assertion
type T [unsafe.Sizeof(<-(make(<-chan T)))]int // nhận channel
type T [unsafe.Sizeof(make(map[int]T)[42])]int // truy cập map
type T [unsafe.Sizeof(*new(T))]int // dereference
// ... và một vài trường hợp khác
Đối với mỗi trường hợp này, trình kiểm tra kiểu có logic bổ sung nơi loại biểu thức giá trị cụ thể đó được đánh giá. Ngay khi chúng ta biết kiểu của giá trị kết quả, chúng ta chèn một bài kiểm tra đơn giản xác nhận rằng kiểu đó là hoàn chỉnh.
Ví dụ, trong ví dụ chuyển đổi type T [unsafe.Sizeof(T(42))]int,
có một đoạn trong trình kiểm tra kiểu giống như:
func callExpr(call *syntax.CallExpr) operand {
x := typeOrValue(call.Fun)
switch x.mode() {
// ... các trường hợp khác
case typeExpr:
// T(), nghĩa là đây là một chuyển đổi
T := x.typ()
// ... xử lý chuyển đổi, T *không an toàn* để phân tách
}
}
Ngay khi chúng ta quan sát thấy CallExpr là một chuyển đổi sang T, chúng ta biết rằng
kiểu kết quả sẽ là T (giả sử không có lỗi trước đó). Trước khi chúng ta trả
lại một giá trị (ở đây là một operand) của kiểu T cho phần còn lại của trình kiểm tra kiểu,
chúng ta cần kiểm tra tính hoàn chỉnh của T:
func callExpr(call *syntax.CallExpr) operand {
x := typeOrValue(call.Fun)
switch x.mode() {
// ... các trường hợp khác
case typeExpr:
// T(), nghĩa là đây là một chuyển đổi
T := x.typ()
+ if !isComplete(T) {
+ reportCycleErr(T)
+ return invalid
+ }
// ... xử lý chuyển đổi, T *an toàn* để phân tách
}
}
Thay vì trả về một giá trị chưa hoàn chỉnh, chúng ta trả về một toán hạng invalid đặc biệt,
báo hiệu rằng biểu thức gọi hàm không thể được đánh giá. Phần còn lại của
trình kiểm tra kiểu có xử lý đặc biệt cho các toán hạng không hợp lệ. Bằng cách thêm điều này,
chúng ta đã ngăn các giá trị chưa hoàn chỉnh “thoát” xuống downstream, cả vào phần còn lại của
logic chuyển đổi kiểu lẫn các toán tử downstream, và thay vào đó đã báo cáo lỗi chu trình
mô tả vấn đề với T.
Một mô hình mã tương tự được sử dụng trong tất cả các trường hợp khác, thực hiện phát hiện chu trình cho các giá trị chưa hoàn chỉnh.
Kết luận
Phát hiện chu trình có hệ thống liên quan đến các giá trị chưa hoàn chỉnh là một bổ sung mới vào trình kiểm tra kiểu. Trước Go 1.26, chúng tôi đã sử dụng một thuật toán xây dựng kiểu phức tạp hơn, bao gồm phát hiện chu trình đặc thù hơn và không phải lúc nào cũng hoạt động. Cách tiếp cận mới, đơn giản hơn của chúng tôi đã giải quyết một số sự cố trình biên dịch (thực sự là bí truyền) (các vấn đề #75918, #76383, #76384, #76478, và nhiều hơn nữa), dẫn đến một trình biên dịch ổn định hơn.
Là các lập trình viên, chúng ta đã quen với các tính năng như định nghĩa kiểu đệ quy và kiểu mảng có kích thước đến mức có thể bỏ qua sự tinh tế của độ phức tạp cơ bản của chúng. Mặc dù bài viết này có bỏ qua một số chi tiết tinh tế hơn, hy vọng chúng ta đã truyền đạt được sự hiểu biết sâu sắc hơn (và có lẽ là sự đánh giá cao hơn) về các vấn đề xung quanh kiểm tra kiểu trong Go.