Reflection

Tất cả code của chương này được lưu tại đâyarrow-up-right

Từ Twitterarrow-up-right

thử thách golang: viết một hàm walk(x interface{}, fn func(string)) nhận vào một struct x và gọi fn cho 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.

Từ The Go Blog: Reflectionarrow-up-right

interface{} là gì?

Chúng ta đã tận hưởng tính an toàn kiểu (type-safety) mà Go mang lại thông qua các hàm làm việc với các kiểu dữ liệu đã biết, chẳng hạn như string, int và các kiểu dữ liệu của riêng chúng ta như BankAccount.

Điều này có nghĩa là chúng ta có sẵn tài liệu miễn phí và compiler sẽ phàn nàn nếu bạn cố gắng truyền sai kiểu dữ liệu vào một hàm.

Tuy nhiên, bạn có thể gặp phải những tình huống muốn viết một hàm mà bạn không biết kiểu dữ liệu tại thời điểm biên dịch (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í danharrow-up-right 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?

  • Với tư cách là người dùng của một hàm nhận interface{}, bạn mất đi tính an toàn kiểu. Điều gì sẽ xảy ra nếu bạn định truyền Herd.species kiểu string vào một hàm nhưng thay vào đó lại truyền Herd.count vốn là kiểu int? Compiler sẽ không thể thông báo cho bạn về lỗi này. Bạn cũng không biết mình được phép truyền cái gì vào hàm. Biết rằng một hàm nhận một UserService chẳng hạn là rất hữu ích.

  • Với tư cách là người viết một hàm như vậy, bạn phải có khả năng kiểm tra bất cứ thứ gì đã được truyền cho mình và cố gắng tìm hiểu xem kiểu dữ liệu đó là gì và bạn có thể làm gì với nó. Điều này được thực hiện bằng cách sử dụng reflection. Điều này có thể khá vụng về, khó đọc và nói chung là kém hiệu quả hơn (vì bạn phải thực hiện các kiểm tra tại thời điểm chạy - 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ó các hàm đa hình (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à tái cấu trúc 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ào fn bởi walk. 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 cho fn để ghi nhận các giá trị vào got.

  • Chúng ta sử dụng một struct ẩn danh với trường Name kiể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 walk với x và hàm giám sát, hiện tại chỉ kiểm tra độ dài của got, 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 bản kiểm thử 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 bản kiểm thử để 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 reflectarrow-up-right 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ố bản kiểm thử 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 tái cấu trúc bản kiểm thử của mình thành một table-driven test (kiểm thử dựa trên bảng) để 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 tái cấu trúc rõ ràng nào ở đây giúp cải thiện mã nguồn, 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 bản kiểm thử 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 Kindarrow-up-right của nó.

Refactor

Một lần nữa, có vẻ như mã nguồn đã đủ 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 bản kiểm thử 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ơnarrow-up-right.

Hãy tái cấu trúc đ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 tái cấu trúc thành switch sẽ cải thiện khả năng đọc và làm cho mã nguồn 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 mã nguồn nhưng tôi cảm thấy cấp độ trừu tượng đã hợp lý hơn.

  • Lấy reflect.Value của x để 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 bản kiểm thử 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 (Field hoặc Index)

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ờ mã nguồn 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 bản kiểm thử, đâ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 tái cấu trúc.

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 bản kiểm thử 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 bản kiểm thử mới, chúng ta không thấy thông báo lỗi. Hãy cố tình làm hỏng bản kiểm thử 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 tái cấu trúc 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 bản kiểm thử, đó 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ơnarrow-up-right.

  • 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