- So với một sự trừu tượng hóa sai lầm, việc trùng lặp mã rẻ hơn rất nhiều, và việc khái quát hóa quá sớm sẽ làm tăng chi phí bảo trì dài hạn
- Ngay cả phần được tách ra vốn hợp lý lúc đầu cũng sẽ dần bị gắn thêm tham số và câu lệnh điều kiện khi yêu cầu thay đổi đôi chút, làm mờ đi ý định ban đầu
- Khi một trừu tượng dùng chung bắt đầu phải gánh nhiều ý tưởng khác nhau, mã sẽ biến thành quy trình xoay quanh điều kiện, và càng thêm tính năng mới thì càng dễ vỡ
- Cần cảnh giác với ngụy biện chi phí chìm khiến ta muốn bảo vệ công sức đã bỏ vào mã hiện có; nếu cần, hãy inline lại phần trừu tượng về chỗ gọi để chỉ giữ phần mã thực sự cần thiết
- Nếu sự trừu tượng hóa sai lầm đã lộ rõ, tốt hơn là đưa trùng lặp trở lại để quan sát lại điểm chung của các yêu cầu hiện tại, rồi sau đó mới tách ra lần nữa
Quá trình hình thành một sự trừu tượng hóa sai lầm
- Câu “duplication is far cheaper than the wrong abstraction” là một phần của bài trình bày tại RailsConf 2014, nhưng về sau vẫn tiếp tục được nhắc đến
- Con đường thất bại thường gặp như sau
- Lập trình viên A phát hiện sự trùng lặp
- Tách phần trùng lặp thành một phương thức hoặc lớp, đặt tên và tạo ra một trừu tượng mới
- Thay đoạn mã lặp ở chỗ gọi bằng lời gọi tới trừu tượng mới
- Theo thời gian, xuất hiện một yêu cầu mới gần giống nhưng không hoàn toàn giống
- Lập trình viên B cố giữ lại trừu tượng hiện có nên thêm tham số và đưa vào câu lệnh điều kiện để đi theo các nhánh khác nhau tùy theo giá trị
- Từ đó trở đi, mỗi yêu cầu mới lại làm tăng thêm tham số và điều kiện, khiến việc hiểu mã ngày càng khó hơn
- Mã một khi đã được tạo ra rất dễ được nhìn như một khoản đầu tư cần phải giữ lại
- Tâm lý tiếc công sức đã bỏ ra bắt đầu tác động
- Mã càng phức tạp và khó hiểu thì càng dễ tạo cảm giác nó hẳn rất quan trọng và đã tốn nhiều thời gian, nên khó bỏ đi hơn
- Điều này gắn với ngụy biện chi phí chìm
Quay lại với trùng lặp rồi tách ra lần nữa
- Nếu tiếp tục triển khai yêu cầu mới trên một trừu tượng sai lầm, mã dùng chung sẽ biến thành cấu trúc xoay quanh các câu lệnh điều kiện, và càng thêm tính năng thì càng bất ổn
- Lúc này, con đường nhanh hơn không phải là tiếp tục đẩy tới mà là quay ngược lại
- Inline lại phần mã đã được trừu tượng hóa vào từng chỗ gọi để đưa trùng lặp trở lại
- Dựa trên các tham số từng được truyền ở mỗi chỗ gọi để xác nhận phần mã nào thực sự được thực thi
- Xóa đi phần mã không cần thiết cho chỗ gọi đó
- Quá trình inline sẽ loại bỏ cả phần trừu tượng lẫn các câu lệnh điều kiện, đồng thời rút gọn mỗi chỗ gọi về chỉ còn phần mã mà nó thực sự cần
- Ngay cả những đoạn mã trông như đang gọi cùng một trừu tượng, trên thực tế mỗi chỗ gọi có thể đã chạy những đường đi mã rất riêng biệt
- Chỉ sau khi loại bỏ hoàn toàn trừu tượng cũ, ta mới có thể quan sát lại sự trùng lặp và tách ra một trừu tượng mới phù hợp với yêu cầu hiện tại
- Nếu tham số và các nhánh điều kiện vẫn liên tục được thêm vào mã dùng chung, rất có thể trừu tượng đó không còn phù hợp nữa
- Ban đầu nó có thể từng là một trừu tượng đúng đắn
- Nhưng khi yêu cầu thay đổi, có thể nó không còn dễ duy trì dưới cùng một hình thức nữa
- Với một sự trừu tượng hóa sai lầm, việc đưa trùng lặp trở lại không phải là thụt lùi mà là bước tiến tốt hơn
5 bình luận
Tôi không chắc đây có phải là một chủ đề cần được diễn giải theo kiểu nhị nguyên hay không.
Ồ, rất đồng cảm.
Những thứ chưa được sắp xếp thì có thể sắp xếp lại, nhưng những thứ đã được sắp xếp rồi thì có vẻ tốn chi phí lớn hơn nhiều để lật lại.
ponytail vừa đăng lên là đã thấy ngay bài này haha
Lúc nào cũng đối đầu nhau nhỉ.
Ý kiến trên Hacker News
Tôi cho rằng nguyên tắc nguồn chân lý duy nhất (single source of truth) luôn phải được giữ vững
Nếu mã bị trùng lặp mà khi chúng khác nhau sẽ trở thành bug thì nên refactor. Nếu không, sẽ xuất hiện liên kết tầm xa khiến dev trong tương lai khó nhận ra cho đến khi bug bùng phát
Tuy nhiên, nếu không vi phạm nguyên tắc đó thì abstraction chỉ là sự tiện lợi, và nếu nó bắt đầu gây bất tiện thì nghĩa là nó không còn làm đúng vai trò, không có lý do gì để dùng nữa. Nếu một hàm cần nhiều cờ để có hành vi tùy biến thì rất có thể đó là một abstraction sai hoặc là vi phạm nguyên tắc trách nhiệm đơn nhất
Nếu thực sự cần rất nhiều tùy biến thì cách nhận hàm/functor làm đối số thường sẽ tốt hơn. Ví dụ, thay vì
solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...)thì có thể làm kiểusolve(f:double -> double, stopping_criteria: StoppingCriteriaClass)Không rõ liệu hai chỗ trong code có đang dùng cùng một thuật toán hay chỉ là hai phiên bản hơi khác nhau, và quan trọng hơn là liệu chúng có thay đổi vì cùng một lý do hay không
Châm ngôn trong tiêu đề nói rằng ép những thứ khác nhau thành giống nhau còn đau đớn hơn việc lặp lại những thứ giống nhau rồi sau đó tách chúng ra cho khác đi, và tôi thấy điều đó đúng. Trường hợp sau thì chỉ cần sửa cùng một thay đổi hai lần hoặc refactor để đưa vào abstraction, còn trường hợp trước thì phải liên tục đắp thêm vào abstraction hoặc hoàn tác nó
Đặc biệt là nó phá vỡ tính cục bộ (locality), trong khi đây thực sự là thuộc tính quan trọng nhất khi thay đổi code. Tôi chỉ muốn thực hiện thay đổi này thôi chứ không muốn lo rằng nó sẽ gây tác dụng phụ ở những phần không liên quan của hệ thống
Ví dụ tiêu biểu là đồng bộ pyproject.toml / requirements.txt đôi khi thực sự là lựa chọn tốt nhất, và có vẻ còn áp dụng rộng hơn được nữa. Tiền đề là mọi thứ đã đi chệch đến mức không thể có một nguồn chân lý duy nhất nữa, nên đây gần với giảm thiểu thiệt hại hơn là chữa trị
Tôi đã nhiều lần gặp cảnh nhìn hai đoạn code thấy giống nhau ở một thời điểm nên abstraction quá đà, để rồi về sau chúng lại tách hướng
Đặc biệt là dev junior đôi khi coi trùng lặp như thể gốc rễ của mọi điều xấu xa
Thỉnh thoảng tôi vẫn nghĩ về vấn đề này. Gần đây tôi gặp nó trong một dự án cá nhân khi xử lý sprite 2D cho unit RTS, nơi sprite unit được đặt trong sprite sheet theo một cách nhất quán: 5 sprite cho 8 hướng, trong đó 3 hướng dùng mirroring, và thứ tự là stand, move, attack, die
Vì vậy tôi đã tạo một loader nhận action + direction và trả về mảng sprite cần phát
Nhưng rồi xuất hiện sprite nổ không có hướng, sprite xác chết chỉ có 4 hướng và 2 hướng mirror, rồi thêm chuyện orc và human phần lớn dùng chung ngoại trừ bốn cái đầu tiên
Tôi có thoáng nghĩ xem abstraction chung của tất cả đống này rốt cuộc là gì, nhưng cuối cùng chỉ tách riêng một phần mã loading và tạo UnitLoader, CorpseLoader, EffectLoader rồi tiếp tục. Ba loader này có xử lý những thứ hơi giống nhau nên có thể vẫn tồn tại một abstraction tốt hơn, nhưng nếu sau này tìm ra thì hẵng tính. Về sau xóa trùng lặp khi đó còn dễ hơn là bây giờ tạo ra một EverythingLoader phức tạp để cố xử lý mọi trường hợp
Trong lập trình có bản năng muốn đơn giản hóa code thông qua khái quát hóa, nhưng thực tế thì bừa bộn nên thường dẫn đến đơn giản hóa quá mức. Như trong bài viết, theo thời gian khi có yêu cầu mới xuất hiện thì mới lộ ra rằng đó là một sự đơn giản hóa quá sớm
“Abstraction quá sớm là nguồn gốc của rất nhiều sự tệ hại” hoàn toàn có thể trở thành một châm ngôn
Ở tầng phía trên, việc diễn giải bố cục sprite sheet và xử lý mode phát có nhiều biến thể, và có thể không tồn tại abstraction chung phù hợp cho mọi trường hợp
Tôi thích cách làm như hiện tại hơn là cố tạo ra một abstraction vô hình hoặc ép mọi thứ vào một abstraction chưa hoàn chỉnh. Chờ đến khi abstraction thật sự rõ ràng và nhu cầu cũng rõ ràng mới làm là điều tốt
Phía đối lập với DRY có một thuốc giải là WET. Ý là hãy viết mọi thứ hai lần, ba lần. Quan trọng hơn, tôi nghĩ chỉ nên abstraction cho các use case đã được chứng minh thực tế, thường là những thứ trước hết đã lộ ra dưới dạng trùng lặp. Code viết cho những use case tương lai còn chưa tồn tại thường lại cản trở việc abstraction những gì mình thực sự đang có, và mỗi lần điều đó xảy ra thì cũng khá buồn cười
Những việc khó và chán có thể để đến khi dự án chạm mốc 10% cuối cùng rồi làm cũng được
Hơn nữa, đôi khi “bug” do trùng lặp tạo ra lại trở thành một tính năng thú vị mà người chơi yêu thích
Hồi còn dùng OOP thì tôi khổ sở vì abstraction, nhưng từ khi chuyển sang cách tiếp cận gần như thuần hàm thì việc trùng lặp code trở nên hiếm hơn nhiều
Chỉ cần tạo một hàm và gọi nó ở hai nơi là xong. Vấn đề abstraction chủ yếu nằm ở cấu trúc dữ liệu, nhưng interface của TypeScript về bản chất là duck typing nên ở đây cũng không gặp quá nhiều rắc rối
Vì vậy, việc trùng lặp code do vấn đề abstraction gây ra là hiếm. Trùng lặp code do dev bị silo hóa thì phổ biến hơn nhiều
Hầu hết ngôn ngữ hiện đại đều có thể dễ dàng đứng trên nền tảng lý thuyết lập trình hàm, không nhất thiết phải biết Haskell. Có thể mỗi người suy nghĩ khác nhau, nhưng với tôi, ý tưởng rằng những bộ phận nhỏ, đơn giản và đôi khi linh hoạt sẽ tạo nên tổng thể là rất hợp lý
Nó đối lập với kiểu cỗ máy biến hình to lớn, phức tạp và làm mọi thứ
Khi quy mô team vượt qua một ngưỡng nào đó và mỗi người không thể biết hết mọi người khác đang làm gì, thì trùng lặp code gần như là điều khó tránh. Dù tất cả đều viết theo phong cách hàm thì cũng vậy
Thực tế tháng trước ở công ty tôi đã có chuyện như thế. Tôi viết một hàm helper thuần mới và đặt ở đầu file, rồi một tuần sau đồng nghiệp nói rằng ở cuối cùng chính file đó đã có một helper gần như làm cùng việc, chỉ khác signature
Trong cùng bối cảnh như bài viết, ai từng trải qua cả hai kiểu này hẳn sẽ đồng ý. Codebase thiết kế chưa đủ dễ xử lý hơn nhiều so với codebase thiết kế quá mức
Đoạn code tệ nhất mà tôi từng phải bảo trì là loại code cố tuân theo DRY. Nhưng lại không hề cố hiểu ý đồ ban đầu của nguyên tắc đó
Cách duy nhất để thoát khỏi mớ hỗn độn ấy là đưa trở lại sự trùng lặp mã nguồn trên phạm vi rộng
Ở đây tôi nhớ tới hai bài nói chuyện: Mike Acton, Data-Oriented Design and C++ [1] và Brian Cantrill, The Complexity of Simplicity [2]
Bài nói của Mike cho rằng lời giải ở mức code không nhất thiết phải mô hình hóa thế giới thực; dữ liệu khác nhau tạo ra các vấn đề khác nhau, vì vậy cần những lời giải khác nhau. Khó mà truyền tải đầy đủ bài nói đó, nhưng nó đã ảnh hưởng lớn tới tôi
Bài nói của Brian bàn về abstraction nói chung và việc tìm ra abstraction “đúng” khó đến mức nào
Hồi trước, khi tôi mới ra trường được vài năm, tôi đang triển khai connection pool bằng Rust, và cách triển khai hợp lý nhất là để đối tượng connection giữ một weak reference tới pool rồi tự động được trả về khi bị drop
Quản lý của tôi khi đó, một người quản lý rất giàu kinh nghiệm, không thích ý tưởng này vì “thư viện giữ sách, chứ sách đâu có giữ thư viện”
Tôi không thấy đó là lý do đủ thuyết phục để đổi thiết kế, nhưng ông ấy không chịu xử lý vấn đề này nếu không nhìn nó qua lăng kính của phép ẩn dụ đó
Cuối cùng bế tắc được phá khi một quản lý khác đề xuất rằng “sách thư viện thì không chứa cả thư viện, nhưng mặt sau có đóng dấu tên thư viện để chỉ nơi trả sách”
Có vẻ người quản lý đó thấy phần mở rộng của phép so sánh này là hợp lý
Nếu khi ấy tôi có nhiều kinh nghiệm hơn, có lẽ tôi đã tìm được cách nói chuyện trong chính phép ẩn dụ đó mà vẫn không nhượng bộ điểm chính. Nhưng đến giờ tôi vẫn thấy hoàn toàn kỳ quặc khi ông ấy cứ khăng khăng dùng phép ẩn dụ đó làm khung chuẩn, thay vì cân nhắc abstraction của code và kết quả đối với trải nghiệm sử dụng thư viện
Không ai chịu nghe đâu. Thật sự là chẳng ai nghe cả. Ở 90% công ty đều có kiểu senior developer tự mê mẩn mỗi khi tạo ra abstraction mới
Thiết kế quá mức, abstraction và tối ưu hóa sớm là ba tai họa lớn của kỹ nghệ phần mềm
Đồng thời tôi cũng vui vì nhờ chúng mà lúc nào cũng sẽ có việc làm
Tương tự, có vẻ một số lập trình viên nghĩ mọi chuỗi inline hay hằng số số học đều là cái ác. Tôi từng thấy thứ này trong một PR
HTTPS_SCHEME = 'https'DOMAIN = 'www.example.com'url = HTTPS_SCHEME + '://' + DOMAINNgoài việc làm theo kiểu cargo cult câu “đừng hardcode hằng số”, tôi không hiểu cách này mang lại lợi ích gì. Nhất là khi phần định nghĩa hằng số nằm ở đầu file, còn đoạn dựng URL thì cách đó hàng trăm dòng
Regex cũng không cần để ở đầu file, cứ để ngay chỗ dùng là được. Ngôn ngữ đủ thông minh để có lẽ vẫn nhận ra đó là hằng số
Nếu chỉ là một hàm rất nhỏ thì cứ dùng lambda. Tôi không muốn tạo ra những hàm một dòng chỉ dùng một hai lần ở một nơi rất xa
Nếu ở test hay staging cần đổi
httpssanghttp, thì việc tách scheme và domain ra, rồi đặt hằng số ở phía trên hoặc ở file riêng, là có lý. Việcurlđược dựng ở nhiều nơi hay chỉ một nơi cũng quan trọngĐặt các hằng số có tên ở đầu file là một phong cách rất phổ biến, đôi khi còn là một phần của tiêu chuẩn code trong team
Cũng có thể còn những lý do khác, nên tốt nhất là nhớ tới Chesterton’s Fence. Dù sao thì vội kết luận đó là cargo cult cũng không phải ý hay. Ai đó cũng hoàn toàn có thể nói dùng inline literal mới là cargo cult. Nếu thấy kỳ lạ thì cứ hỏi; có thể có lý do chính đáng, hoặc cũng có thể chẳng ai để ý nên nếu bạn refactor đưa hằng số inline trở lại thì họ còn thích hơn
Nếu tách nó thành hằng số, tôi lại phải mở từng project lên để tìm nơi sử dụng
Dùng microservice thì bạn có thể làm cả hai
Nếu bạn là người bảo trì một service, thì không có lý do gì phải bận tâm tới code nằm ở service khác. Đó là code của team khác, tại sao bạn phải quan tâm? Thậm chí bạn còn không cần biết team đó tồn tại. Trong những hệ thống lớn, việc thực tế không thể biết hết mọi ứng dụng đang tồn tại cũng là chuyện bình thường
Chỉ với $19.95, chúng tôi sẽ biến một điểm lỗi đơn thành nhiều điểm lỗi đơn!
Thà dùng kiến trúc hướng dịch vụ nhưng cứ triển khai như một monolith còn hơn. Test sẽ dễ hơn và cũng tránh được lớp bổ sung của serialize/deserialize
Tôi nghĩ đa số senior đều biết không nên mù quáng làm theo DRY. Dù vậy, nhiều người trong chúng ta vẫn cảm thấy không thoải mái với ý nghĩ phải duy trì nhiều nguồn mã bị trùng lặp
Để xử lý điều này, cần xem kỹ mô hình đơn giản trong đó hai bên gọi phụ thuộc vào mã dùng chung. Nếu mã dùng chung phải thay đổi chỉ vì nhu cầu của một bên gọi, thì đoạn mã đó không thuộc về phần dùng chung
Mục tiêu sai lầm của DRY là cố giải quyết chuyện này bằng đóng gói. Đóng gói chuyển công việc refactor từ bên gọi sang mã dùng chung. Nhưng vì tác động của việc cập nhật mã dùng chung lớn hơn rất nhiều so với ở bên gọi, nên đó không phải hướng đi mong muốn
Vẫn có thể giữ DRY mà tránh đóng gói. Tốt hơn là có nhiều lớp trừu tượng mỏng mà bên gọi cần nhận biết. Trong OOP, điều này được học thông qua SRP và IoC; còn trong lập trình thủ tục, nó tự nhiên xuất hiện dưới dạng gọi một loạt hàm helper