7 điểm bởi GN⁺ 5 giờ trước | 5 bình luận | Chia sẻ qua WhatsApp
  • 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

 
dieafterwork 1 giờ trước

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.

 
hanje3765 2 giờ trước

Ồ, 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.

 
jimmy2056 3 giờ trước

ponytail vừa đăng lên là đã thấy ngay bài này haha

 
shakespeares 4 giờ trước

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ểu solve(f:double -> double, stopping_criteria: StoppingCriteriaClass)

    • Điểm cốt lõi của bài viết là nói về những trường hợp vẫn chưa rõ có bao nhiêu nguồn chân lý
      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
    • Nếu vì áp lực quá lớn mà phần mềm đã bị đẩy sang trạng thái có hai nguồn chân lý, thì thêm một bài test CI để không merge vào main nếu hai nguồn không khớp nhau là cách khá hữu ích
      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ị
    • Tiêu chí “khác đi là thành bug” là một rule of thumb rất tốt
      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
    • Về mặt lý thuyết thì đúng, nhưng trong thực tế có rất nhiều người cố tránh mọi kiểu trùng lặp bằng mọi giá
      Đặ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

    • Tôi rất thích câu “Mọi thứ nên đơn giản nhất có thể, nhưng không được đơn giản hơn thế”
      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
    • Rất có thể abstraction chung đã được tách ra sẵn rồi. Đó là đoạn code load và hiển thị pixel của một sprite đơn lẻ
      Ở 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
    • Cách làm này là đúng. Làm game vốn dĩ nên vui
      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

    • Tôi dùng ngôn ngữ hàm như một sở thích, và tôi nghĩ điều cốt lõi cần nhớ là kỹ thuật
      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ứ
    • Để gặp trùng lặp code thì dev không nhất thiết phải bị silo hóa
      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
    • Tôi tò mò không rõ chính xác “gọi hàm từ hai phần” nghĩa là gì
  • 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

    • Không sao đâu, cứ yên tâm triển khai thêm vài tham số boolean mơ hồ vào hàm tái sử dụng để hỗ trợ use case mới là được
    • Điều cốt lõi là họ đã “cố làm”. Họ làm vậy một thời gian cho đến khi đi tới điểm không thể tiếp tục bám theo nó một cách trung thành nữa, vì abstraction đó vốn đã sai ngay từ đầu
  • Ở đâ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

    1. https://www.youtube.com/watch?v=rX0ItVEVjHc
    2. https://www.youtube.com/watch?v=Cum5uN2634o
    • Tôi luôn thấy lạ khi ngay cả những kỹ sư khá thông minh đôi lúc vẫn ưu tiên ẩn dụ thế giới thực hơn nhu cầu thực tế của codebase
      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

    • Kubernetes, microservice nhiều hơn cả số kỹ sư, các giao thức phức tạp chỉ để tiết kiệm vài byte overhead, đưa mọi thứ lên cloud, và vô số class lẽ ra chỉ cần là một hàm đơn giản — đều là ví dụ điển hình
  • 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 + '://' + DOMAIN
    Ngoà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

    • Trong code, tôi cực kỳ thích sự gần nhau. Tôi thích thứ gì được định nghĩa càng gần nơi dùng càng tốt. Đây là một thói quen rất khó chịu
      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
    • Đặt hằng số ở đầu file sẽ dễ tùy biến hơn. Điều này càng đúng nếu file đó bị sao chép ra nhiều nơi
      Nếu ở test hay staging cần đổi https sang http, 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ệc url đượ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
    • Tôi cũng từng gặp chuyện này. Nếu Event có tên riêng, tôi có thể grep ngay trên toàn bộ monolith khổng lồ hoặc cả đống repo microservice để tìm mọi file liên quan tới event đó
      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

    • Tôi biết đây là câu đùa, nhưng trong thế giới microservice lý tưởng thì không tồn tại khái niệm trùng lặp mã nguồn giữa các service
      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ờ đã! Còn nữa!
      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!
    • 9 trên 10 lần, microservice sẽ phụ thuộc lẫn nhau rất nặng và trở thành một distributed monolith
      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 SRPIoC; 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