Reflection
Tất cả code của chương này được lưu tại đây
thử thách golang: viết một hàm
walk(x interface{}, fn func(string))nhận vào một structxvà gọifncho tất cả các trường (fields) kiểu string được tìm thấy bên trong. mức độ khó: đệ quy.
Để làm điều này, chúng ta sẽ cần sử dụng reflection.
Reflection trong lập trình là khả năng của một chương trình để kiểm tra cấu trúc của chính nó, đặc biệt là thông qua các kiểu dữ liệu; đó là một hình thức của metaprogramming. Nó cũng là một nguồn gây ra nhiều bối rối.
interface{} là gì?
interface{} là gì?Chúng ta đã tận hưởng type-safety của Go khi làm việc với các kiểu đã biết như string, int hay BankAccount.
Nhờ đó, chúng ta có tài liệu sẵn và compiler sẽ báo lỗi nếu truyền sai kiểu vào hàm.
Tuy nhiên, đôi khi bạn cần viết hàm mà không biết trước kiểu dữ liệu tại compile time.
Go cho phép chúng ta vượt qua điều này với kiểu interface{}, bạn có thể coi nó là bất kỳ kiểu dữ liệu nào (thực tế, trong Go any là một bí danh cho interface{}).
Vì vậy, walk(x interface{}, fn func(string)) sẽ chấp nhận bất kỳ giá trị nào cho x.
Vậy tại sao không sử dụng interface{} cho mọi thứ để có các hàm thực sự linh hoạt?
interface{} cho mọi thứ để có các hàm thực sự linh hoạt?Người dùng hàm nhận
interface{}mất đi type-safety. Nếu bạn định truyềnHerd.species(kiểustring) nhưng lại truyền nhầmHerd.count(kiểuint)? Compiler không báo lỗi. Bạn cũng không biết mình được truyền gì vào. Biết hàm nhậnUserServicechẳng hạn sẽ rõ ràng hơn nhiều.Người viết hàm phải kiểm tra bất cứ thứ gì được truyền vào và xác định kiểu dữ liệu. Việc này dùng reflection — khá rườm rà, khó đọc và kém hiệu quả hơn vì phải kiểm tra tại runtime.
Tóm lại, chỉ sử dụng reflection nếu bạn thực sự cần.
Nếu bạn muốn có polymorphic functions, hãy cân nhắc xem liệu bạn có thể thiết kế nó dựa trên một interface (khác với interface{}, điều này hơi gây bối rối) để người dùng có thể sử dụng hàm của bạn với nhiều kiểu dữ liệu nếu họ triển khai các phương thức cần thiết để hàm của bạn hoạt động.
Hàm của chúng ta sẽ cần có khả năng hoạt động với nhiều thứ khác nhau. Như mọi khi, chúng ta sẽ thực hiện theo cách lặp đi lặp lại, viết các test cho từng thứ mới mà chúng ta muốn hỗ trợ và refactor trên đường đi cho đến khi hoàn thành.
Viết test trước tiên
Chúng ta muốn gọi hàm của mình với một struct có một trường string trong đó (x). Sau đó, chúng ta có thể giám sát hàm (fn) được truyền vào để xem nó có được gọi hay không.
Chúng ta muốn lưu trữ một slice các chuỗi (
got) để lưu những chuỗi nào đã được truyền vàofnbởiwalk. Thông thường trong các chương trước, chúng ta đã tạo các kiểu chuyên dụng cho việc này để giám sát các lời gọi hàm/phương thức nhưng trong trường hợp này, chúng ta chỉ có thể truyền vào một hàm ẩn danh chofnđể ghi nhận các giá trị vàogot.Chúng ta sử dụng một
structẩn danh với trườngNamekiểu string để thực hiện trường hợp "thành công" đơn giản nhất.Cuối cùng, gọi
walkvớixvà hàm giám sát, hiện tại chỉ kiểm tra độ dài củagot, chúng ta sẽ đưa ra các xác nhận (assertions) cụ thể hơn sau khi đã làm cho chức năng cơ bản hoạt động.
Thử chạy test
Viết lượng code tối thiểu để chạy test và kiểm tra kết quả lỗi
Chúng ta cần định nghĩa hàm walk:
Thử chạy lại test:
Viết đủ code để test chạy thành công
Chúng ta có thể gọi hàm giám sát với bất kỳ chuỗi nào để làm test này vượt qua.
Test bây giờ sẽ vượt qua. Việc tiếp theo chúng ta cần làm là thực hiện một xác nhận cụ thể hơn về những gì fn đang được gọi.
Viết test trước tiên
Thêm đoạn sau vào test hiện có để kiểm tra xem chuỗi được truyền cho fn có đúng không:
Thử chạy test
Viết đủ code để test chạy thành công
Đoạn mã này rất không an toàn và rất ngây thơ, nhưng hãy nhớ: mục tiêu của chúng ta khi đang ở trạng thái "đỏ" (các test thất bại) là viết lượng mã nhỏ nhất có thể. Sau đó, chúng ta sẽ viết thêm các test để giải quyết các mối lo ngại của mình.
Chúng ta cần sử dụng reflection để xem xét x và thử nhìn vào các thuộc tính của nó.
Package reflect có một hàm ValueOf trả về cho chúng ta một Value của một biến cho trước. Nó có các cách để chúng ta kiểm tra một giá trị, bao gồm các trường của nó mà chúng ta sử dụng ở dòng tiếp theo.
Sau đó, chúng ta đưa ra một số giả định rất lạc quan về giá trị được truyền vào:
Chúng ta nhìn vào trường đầu tiên và duy nhất. Tuy nhiên, có thể không có trường nào cả, điều này sẽ gây ra panic.
Sau đó, chúng ta gọi
String(), hàm này trả về giá trị bên dưới dưới dạng một chuỗi. Tuy nhiên, điều này sẽ sai nếu trường đó là một kiểu dữ liệu khác không phải chuỗi.
Refactor
Mã của chúng ta đang vượt qua trường hợp đơn giản nhưng chúng ta biết rằng mã của mình còn nhiều thiếu sót.
Chúng ta sẽ viết một số test nơi chúng ta truyền vào các giá trị khác nhau và kiểm tra mảng chuỗi mà fn được gọi.
Chúng ta nên refactor test của mình thành một table-driven test để dễ dàng tiếp tục kiểm thử các kịch bản mới.
Bây giờ chúng ta có thể dễ dàng thêm một kịch bản để xem điều gì xảy ra nếu chúng ta có nhiều hơn một trường string.
Viết test trước tiên
Thêm kịch bản sau vào cases.
Thử chạy test
Viết đủ code để test chạy thành công
val có một phương thức NumField trả về số lượng trường trong giá trị đó. Điều này cho phép chúng ta lặp qua các trường và gọi fn, làm cho test vượt qua.
Refactor
Có vẻ như không có bước refactor rõ ràng nào ở đây giúp cải thiện code, vì vậy chúng ta hãy tiếp tục.
Thiếu sót tiếp theo trong walk là nó giả định mọi trường đều là một string. Hãy viết một test cho kịch bản này.
Viết test trước tiên
Thêm trường hợp sau:
Thử chạy test
Viết đủ code để test chạy thành công
Chúng ta cần kiểm tra xem kiểu của trường có phải là string hay không.
Chúng ta có thể thực hiện điều đó bằng cách kiểm tra Kind của nó.
Refactor
Một lần nữa, có vẻ như code đã đủ hợp lý cho thời điểm hiện tại.
Kịch bản tiếp theo là điều gì sẽ xảy ra nếu nó không phải là một struct "phẳng"? Nói cách khác, điều gì xảy ra nếu chúng ta có một struct với một số trường lồng nhau?
Viết test trước tiên
Chúng ta đã sử dụng cú pháp struct ẩn danh để khai báo các kiểu dữ liệu cho test của mình, vì vậy chúng ta có thể tiếp tục làm như vậy:
Nhưng chúng ta có thể thấy rằng khi bạn có các struct ẩn danh bên trong, cú pháp sẽ trở nên hơi lộn xộn. Đã có một đề xuất để làm cho cú pháp này đẹp hơn.
Hãy refactor điều này bằng cách tạo một kiểu dữ liệu cụ thể cho kịch bản này và tham chiếu nó trong test. Có một chút gián tiếp ở chỗ một phần mã cho test của chúng ta nằm bên ngoài test, nhưng người đọc vẫn có thể suy ra cấu trúc của struct bằng cách nhìn vào phần khởi tạo.
Thêm các khai báo kiểu sau vào đâu đó trong file test của bạn:
Giờ đây chúng ta có thể thêm thông tin này vào các trường hợp kiểm thử, điều này dễ đọc hơn nhiều so với trước đây:
Thử chạy test
Vấn đề là chúng ta chỉ đang lặp qua các trường ở cấp độ đầu tiên của hệ thống phân cấp kiểu.
Viết đủ code để test chạy thành công
Giải pháp khá đơn giản, chúng ta lại kiểm tra Kind của nó và nếu nó tình cờ là một struct, chúng ta chỉ cần gọi lại walk trên struct bên trong đó.
Refactor
Khi bạn thực hiện so sánh trên cùng một giá trị nhiều hơn một lần, nói chung việc refactor thành switch sẽ cải thiện khả năng đọc và làm cho code của bạn dễ mở rộng hơn.
Điều gì xảy ra nếu giá trị của struct được truyền vào là một con trỏ (pointer)?
Viết test trước tiên
Thêm trường hợp này:
Thử chạy test
Viết đủ code để test chạy thành công
Bạn không thể sử dụng NumField trên một Value của con trỏ, chúng ta cần trích xuất giá trị bên dưới trước khi có thể làm điều đó bằng cách sử dụng Elem().
Refactor
Hãy bao đóng trách nhiệm trích xuất reflect.Value từ một interface{} cho trước vào một hàm.
Điều này thực sự làm tăng thêm một chút code nhưng tôi cảm thấy cấp độ trừu tượng đã hợp lý hơn.
Lấy
reflect.Valuecủaxđể tôi có thể kiểm tra nó, tôi không quan tâm bằng cách nào.Lặp qua các trường, thực hiện bất cứ điều gì cần làm tùy thuộc vào kiểu của nó.
Tiếp theo, chúng ta cần xử lý các slice.
Viết test trước tiên
Thử chạy test
Viết lượng code tối thiểu để chạy test và kiểm tra kết quả lỗi
Trường hợp này tương tự như kịch bản con trỏ ở trên, chúng ta đang cố gắng gọi NumField trên reflect.Value của mình nhưng nó không có vì đó không phải là một struct.
Viết đủ code để test chạy thành công
Refactor
Đoạn mã này hoạt động nhưng trông hơi tệ. Đừng lo lắng, chúng ta có mã đang hoạt động được hỗ trợ bởi các test nên chúng ta có thể tự do chỉnh sửa theo ý muốn.
Nếu bạn suy nghĩ hơi trừu tượng một chút, chúng ta muốn gọi walk trên cả:
Mỗi trường trong một struct
Mỗi mục trong một slice
Hiện tại mã của chúng ta đang thực hiện điều này nhưng chưa thể hiện nó một cách rõ ràng. Chúng ta chỉ có một kiểm tra ở đầu để xem nó có phải là một slice hay không (với lệnh return để dừng các đoạn mã còn lại) và nếu không, chúng ta chỉ mặc nhiên coi nó là một struct.
Hãy sửa lại mã để thay vào đó chúng ta kiểm tra kiểu trước tiên, sau đó mới thực hiện công việc.
Trông tốt hơn nhiều rồi đấy! Nếu đó là một struct hoặc slice, chúng ta lặp qua các giá trị của nó và gọi walk cho từng giá trị. Ngược lại, nếu đó là một reflect.String, chúng ta có thể gọi fn.
Tuy nhiên, đối với tôi, nó vẫn có thể tốt hơn nữa. Có sự lặp lại của hoạt động lặp qua các trường/giá trị và sau đó gọi walk, nhưng về mặt khái niệm chúng là giống nhau.
Nếu value là một reflect.String thì chúng ta chỉ cần gọi fn như bình thường.
Ngược lại, lệnh switch của chúng ta sẽ trích xuất ra hai thứ tùy thuộc vào kiểu dữ liệu:
Có bao nhiêu trường (fields)
Cách trích xuất
Value(FieldhoặcIndex)
Khi đã xác định được những thứ đó, chúng ta có thể lặp qua numberOfValues và gọi walk với kết quả của hàm getField.
Khi chúng ta làm xong việc này, việc xử lý các mảng (arrays) sẽ trở nên rất đơn giản.
Viết test trước tiên
Thêm vào các trường hợp kiểm thử:
Thử chạy test
Viết đủ code để test chạy thành công
Mảng có thể được xử lý giống như slice, vì vậy chỉ cần thêm nó vào cùng một trường hợp bằng dấu phẩy:
Kiểu dữ liệu tiếp theo chúng ta muốn xử lý là map.
Viết test trước tiên
Thử chạy test
Viết đủ code để test chạy thành công
Một lần nữa, nếu bạn suy nghĩ hơi trừu tượng một chút, bạn sẽ thấy rằng map rất giống với struct, chỉ là các key không được biết trước tại thời điểm biên dịch.
Tuy nhiên, theo thiết kế của Go, bạn không thể lấy các giá trị ra khỏi một map bằng chỉ số (index). Nó chỉ được thực hiện thông qua key, vì vậy điều này phá hỏng sự trừu tượng của chúng ta, thật tệ.
Refactor
Bạn cảm thấy thế nào ngay lúc này? Có vẻ như đó là một sự trừu tượng hay vào thời điểm đó nhưng bây giờ code cảm thấy hơi lộn xộn.
Điều này không sao cả! Tái cấu trúc là một hành trình và đôi khi chúng ta sẽ mắc sai lầm. Điểm quan trọng của TDD là nó cho chúng ta sự tự do để thử những điều này.
Bằng cách thực hiện các bước nhỏ được hỗ trợ bởi các test, đây hoàn toàn không phải là một tình huống không thể vãn hồi. Hãy đưa nó trở lại trạng thái trước khi refactor.
Chúng ta đã đưa vào hàm walkValue để áp dụng nguyên tắc DRY cho các lời gọi walk bên trong lệnh switch, nhờ đó chúng chỉ cần trích xuất các reflect.Value từ val.
Một vấn đề cuối cùng
Hãy nhớ rằng các map trong Go không đảm bảo thứ tự. Vì vậy, các test của bạn đôi khi sẽ thất bại vì chúng ta khẳng định rằng các lời gọi đến fn được thực hiện theo một thứ tự cụ thể.
Để khắc phục điều này, chúng ta cần chuyển phần xác nhận với map sang một test mới nơi chúng ta không quan tâm đến thứ tự.
Đây là cách assertContains được định nghĩa:
Vì chúng ta đã trích xuất các map vào một test mới, chúng ta không thấy thông báo lỗi. Hãy cố tình làm hỏng test with maps ở đây để bạn có thể kiểm tra thông báo lỗi, sau đó sửa lại để tất cả các test vượt qua.
Kiểu dữ liệu tiếp theo chúng ta muốn xử lý là chan.
Viết test trước tiên
Thử chạy test
Viết đủ code để test chạy thành công
Chúng ta có thể lặp qua tất cả các giá trị được gửi qua channel cho đến khi nó được đóng bằng cách sử dụng Recv().
Kiểu dữ liệu tiếp theo chúng ta muốn xử lý là func (hàm).
Viết test trước tiên
Thử chạy test
Viết đủ code để test chạy thành công
Các hàm không có đối số dường như không có nhiều ý nghĩa trong kịch bản này. Tuy nhiên, chúng ta nên cho phép có các giá trị trả về tùy ý.
Tổng kết
Đã giới thiệu một số khái niệm từ package
reflect.Đã sử dụng đệ quy (recursion) để duyệt qua các cấu trúc dữ liệu tùy ý.
Đã thực hiện một bước refactor mà khi nhìn lại thấy không ổn nhưng không quá bối rối vì điều đó. Bằng cách làm việc lặp đi lặp lại với các test, đó không phải là vấn đề quá lớn.
Chương này chỉ đề cập đến một khía cạnh nhỏ của reflection. The Go blog có một bài viết tuyệt vời đề cập đến nhiều chi tiết hơn.
Bây giờ bạn đã biết về reflection, hãy cố gắng hết sức để tránh sử dụng nó.
Last updated