Cohesion và Coupling

Khi học môn Công nghệ phần mềm, cohesion và coupling là hai khái niệm mà HM đôi khi cảm thấy hơi lẫn lộn. Hi vọng bài viết này sẽ phần nào giúp những người cùng cảnh ngộ phân biệt chúng rõ ràng hơn.

Trước hết, hãy nói về nguyên tắc chung khi thiết kế chương trình:

  1. Giữ những thứ phải thay đổi cùng nhau ở thật gần nhau. Chẳng hạn khi thay đổi về thiết kế ở class A luôn kéo theo thay đổi ở B, thì nên cho A và B vào cùng một module.
  2. Cho phép những thứ không liên quan thay đổi một cách độc lập với nhau (nói cho có vẻ “chuyên ngành” là orthogonal). Ví dụ không có lí do gì để sửa đổi class Person khi class Money bị sửa đổi.

Từ hai nguyên tắc này có thể đẻ ra cả tá nguyên tắc và khái niệm cụ thể hơn, mà trong đó có cohesion và coupling. Tuy nhiên, nếu nắm vững hai nguyên tắc trên thì trong trường hợp không biết về các khái niệm khác, bạn vẫn có thể nhận ra những bất thường trong thiết kế.

Vậy cohesion là gì? Khi nói đến cohesion chúng ta nghĩ đến nhiệm vụ của từng module. Nhiệm vụ của từng module càng rõ ràng và tách biệt thì cohesion càng cao (high cohesion), và đó là mục tiêu cần đạt tới khi thiết kế. Giải thích bằng code có lẽ sẽ không rõ ràng bằng bài viết này:

Tại kỳ họp Quốc hội thứ năm, khi thảo luận về quản lý chất lượng vệ sinh an toàn thực phẩm có vị đại biểu Quốc hội đã ví việc có tới 5 bộ chịu trách nhiệm chính như vậy cũng giống như “nhiều sãi không ai đóng cửa chùa”.
Bởi thế, làm rõ trách nhiệm của từng cơ quan quản lý Nhà nước về an toàn thực phẩm là một yêu cầu được nhấn mạnh khi xây dựng dự Luật An toàn thực phẩm.

Thực ra không có một phương pháp tuyệt đối để xác định những nhiệm vụ nào thì được coi là liên quan và nên được xếp vào cũng một module, hay những nhiệm vụ nào là không phù hợp với một module nào đó. Tuy nhiên, có một cách rất hữu dụng, là kiểm tra tên của một class hay method. Nếu bạn không thể kiếm một cái tên phù hợp cho class, bạn nên kiểm tra xem nhiệm vụ của module tương ứng đã được định nghĩa rõ ràng chưa. Cũng vậy, nếu tên của một method có từ and hoặc chứa hai hành động trở lên, thì bạn nên xem xét phân tách method thành nhiều method nhỏ hơn. Về các lời khuyên liên quan đến cách đặt tên, xem thêm Code complete.

Tiếp theo là coupling. Coupling là khái niệm chỉ độ phụ thuộc giữa các module với nhau khi thực hiện một chức năng nào đó. Cũng như cohesion, coupling được chia làm nhiều cấp độ, và một thiết kế tốt sẽ cho coupling thấp (loose coupling). Thế nào là phụ thuộc? Đó là khi một module khi hoạt động phải hiểu biết về chức năng và cách hoạt động của các module khác. Chẳng hạn, khi một class dùng chung một vùng nhớ (một object chẳng hạn) với class khác, thì class này phải hiểu rõ class kia sẽ làm gì với vùng nhớ chung đó, và vậy là có coupling giữa hai class. Có rất nhiều dạng coupling, có dạng chấp nhận được, có dạng nên hạn chế, bạn có thể xem thêm trong Code complete.

Lí do phải giảm coupling là vì nếu nhiều module couple chặt với nhau, thì khi bạn thiết kế lại một module, bạn sẽ phải kiểm tra tất cả các module còn lại, và coupling càng chặt thì khả năng các module còn lại này bị thay đổi càng cao. Ngoài ra, có một số dạng coupling ngầm tương đối khó nhận ra, điều này sẽ làm cho việc thay đổi càng khó khăn hơn. Bạn có thể liên hệ với thủ tục hành chính. Nếu người dân phải đi qua nhiều bước thủ tục thì khi các bước này bị thay đổi, người ta phải tuyên truyền lại cho nhiều chục triệu người dân lẫn nhân viên hành chính. Nếu các bước thủ tục này được encapsulate lại thành một bước duy nhất (“một cửa”) thì người ta chỉ phải đến, đưa hồ sơ (“input”) và lấy kết quả (“output”). Thay đổi về thủ tục bây giờ chỉ đòi hỏi đào tạo lại các nhân viên bên trong.

Bạn có thể thấy cohesion và coupling phần nào phản ánh hai nguyên tắc ban đầu.

Khác nhau giữa kiến trúc phân lớp (layered) và kiến trúc n-tier

(Cập nhật: Chuyển tới đọc bài này thay vì đọc tiếp phần ở dưới.)

HM thấy trên mạng có một số người hỏi về kiến trúc phân lớp (layered) và kiến trúc n-tier. Cũng như nhiều khái niệm khác, nếu tỉ mỉ đi vào chi tiết thì chắc không có định nghĩa thống nhất, tuy nhiên nếu để nắm ý tưởng chung, thì có lẽ tốt nhất là xem "Microsoft application architecture guide" (Microsoft viết mà sai thì chịu thôi :D). Tải miễn phí tại Microsoft, hoặc đọc trong mục patterns & practices của MSDN. Dưới đây tóm lược các ý chính:

Kiến trúc phân lớp nhóm các chức năng liên quan đến nhau trong từng lớp (layer). Các chức năng ở một lớp khi làm nhiệm vụ của mình có thể sử dụng các chức năng mà lớp dưới nó cung cấp. Có hai dạng

  • Strict layering: Lớp trên chỉ liên hệ với lớp ở ngay dưới nó.
  • Relaxed layering: Lớp trên có thể liên hệ với nhiều lớp dưới nó.

Khi nói về kiến trúc phân lớp, chúng ta nói về cách tổ chức luận lí của code, ví dụ thông qua các package, namespace, module, v.v. Không hề có gì gợi ý rằng các lớp phải chạy trên các máy khác nhau, hay thậm chí trong các process khác nhau.

Tier là nơi các lớp chạy. Các tier phải tách biệt với nhau về memory space. Nhưng cũng không có nghĩa là các tier phải chạy trên các máy khác nhau. Trong hệ điều hành, các process tách biệt với nhau về memory space, cho nên nếu bạn có một chương trình với hai lớp Presentation và Business Services chạy trên hai process giao tiếp với nhau dùng Web services thì bạn cũng đã có một hệ thống 2-tier rồi.

Các lớp có thể ở cùng một một tier, hoặc được phân tán trên nhiều tier (n-tier). Vậy một tier có thể có nhiều lớp. Tuy nhiên, thông thường với các hệ thống lớn, các tier sẽ không ở trên cùng một máy vật lí, vì các lí do về performance, scalability, fault tolerance, security v.v.

Khác biệt giữa Parallel.ForEach() và ParallelEnumerable.ForAll() trong .NET framework

Parallel.ForEach() là một static method cho phép thực hiện song song các thao tác trên một Enumerable object. ParallelEnumerable.ForAll() là một extension method cho phép thực hiện song song các thao tác trên một Enumerable object do một câu truy vấn tạo ra (hơi khó giải thích). Tuy nhiên, không chỉ khác biệt về cách sử dụng, chúng cũng có khác biệt về performance nói chung.

Giả sử bạn gặp tình huống xử lí trên một biến data kiểu Enumerable như sau:

var q = from d in data … select d;
foreach (var item in q) { doSomething(item); }

Nếu bạn muốn câu truy vấn q chạy ở chế độ song song (chẳng hạn, khi bạn có nhiều core và muốn tận dụng chúng) bạn dùng ParallelEnumerable.AsParallel():

var q = from d in data.AsParallel() … select d;
foreach (var item in q) { doSomething(item); }

Và nếu hàm doSomething() không quan tâm đến thứ tự xử lí của item (tức là gọi doSomething với item nào trước thì kết quả cũng ra như nhau) thì bạn cũng có thể chạy foreach ở chế độ song song:

var q = from d in data.AsParallel() … select d;
Parallel.ForEach(q, item => { doSomething(item) });

Nhưng bạn cũng có thể dùng ForAll với câu truy vấn q để đạt hiệu quả tương tự:

var q = (from d in data.AsParallel() … select d);
q.ForAll(item => { doSomething(item) });

Nhìn hai đoạn code cuối ta có cảm giác performance của chúng giống nhau, nhưng thực ra không phải như vậy. Tạm gọi đoạn code dùng Parallel.ForEach() là đoạn code (1) và đoạn code dùng ParallelEnumerable.ForAll() là đoạn code (2).

Các công việc mà đoạn code (1) thực hiện (không hoàn toàn chính xác trên thực tế, nhưng đủ để cho thấy sự khác biệt) là:

  • Chạy câu truy vấn:
    • Phân hoạch data ra thành nhiều phần (để chạy song song).
    • Chạy câu truy vấn trên từng data, tạm gọi kết quả là q1, q2, q3, v.v.
    • Trộn q1, q2, q3, v.v lại thành Enumerable object q.
  • Chạy Parallel.ForEach():
    • Phân hoạch q ra thành nhiều phần (để chạy song song).
    • Gọi doSomething();

Sơ đồ minh họa cho đoạn code (1).

image

Đoạn code (1) lãng phí ở chỗ đầu tiên ta trộn các q1, q2, q3, v.v lại thành q, sau đó lại phân hoạch q ra. Sẽ tốt hơn nếu sau khi phân hoạch data, câu truy vấn được chạy, và các item lấy ra sẽ được cho vào doSomething() luôn. Nói cách khác doSomething() trở thành một phần trong hoạt động của câu truy vấn. Đó chính là cách mà đoạn code (2) thi hành. Dưới đây là sơ đồ minh họa cho đoạn code (2):

image

Đó là lí do lại sao đoạn code (2) cho performance tốt hơn, và được khuyên dùng trong những tình huống tương tự.

Nếu muốn tìm hiểu thêm về Parallel trong C#, bạn có thể đọc Paterns of Parallel Programming (sách miễn phí từ Microsoft).

Paradigm và ngôn ngữ lập trình

Trước đây, đa phần lập trình viên bình thường chủ yếu làm việc với lập trình hướng đối tượng, thì sự khác biệt giữa paradigm và ngôn ngữ lập trình không thật quan trọng. Tuy nhiên, khi mà lập trình hàm (functional programming) đang ngày một thu hút nhiều sự chú ý, thì việc hiểu rõ sự khác biệt này là khá cần thiết.

Trước hết, bạn thử suy nghĩ, tại sao Java không giống C#, mà người ta lại dạy bạn là hai ngôn ngữ này đều là lập trình hướng đối tượng? Nếu bạn gặp thêm một ngôn ngữ X nào đó, làm sao bạn biết nó có hướng đối tượng? Hay nếu bạn vô tình đọc phải một cuốn như Object Oriented Programming With ANSI-C, bạn có dám nói C là một ngôn ngữ lập trình hướng đối tượng không?

Để trả lời những câu hỏi này, bạn phải hiểu lập trình hướng đối tượng là một paradigm. Paradigm định nghĩa các khái niệm mà chúng ta sẽ dùng để suy nghĩ về một vấn đề nào đó. Một số ngôn ngữ nào đó có thể cung cấp các tính năng cho phép chúng ta ứng dụng paradigm đó để hiện thực một cách dễ dàng, nhưng paradigm không bao giờ quy định cụ thể tính năng đó là gì, cú pháp ra sao, v.v. Chẳng hạn, paradigm cho lập trình hướng đối tượng nêu lên khái niệm encapsulation. Java dùng các modifier như public, private. Những người phát triển C# cho rằng property giúp tăng cường tính encapsulation, và họ đưa property vào những ngôn ngữ này. Những người phát triển Python lại lập luận rằng encapsulation không phải nhiệm vụ của trình dịch, nên trong Python không có cơ chế tạo private method, người ta ngầm quy ước method bắt đầu với “__” là private. Và cả Java, C#, Python đều được người phát triển chúng tuyên bố là có hỗ trợ lập trình hướng đối tượng. Nếu bạn hiểu encapsulation, bạn sẽ việc được code thỏa mãn tương đối tính chất này ở cả ba ngôn ngữ, nhưng nếu bạn chỉ học ngôn ngữ thôi, có thể code của bạn sẽ không có tính encapsulation. Hiểu một paradigm không chỉ giúp bạn khi sử dụng một ngôn ngữ được cho là hỗ trợ nó, mà bạn có thể áp dụng những khái niệm hay từ nó cho các ngôn ngữ khác, để làm cho code của bạn có chất lượng cao hơn (bạn thử nghĩ làm thể nào để tăng encapsulation trong C).

Cần chú ý là trong khi một ngôn ngữ có thể tốt hơn một ngôn ngữ khác, như C# 3.0 so với C# 2.0 (mặc dù so sánh này chỉ là tương đối), bạn không thể so sánh paradigm này có tốt hơn paradigm khác không. Mỗi paradigm đều thích hợp trong một số tình huống, và không thích hợp trong những tình huống khác. Ví dụ, paradigm lập trình hàm cổ vũ cho việc sử dụng immutable object, nhưng không phải mọi bài toán đều giải quyết được chỉ dùng immutable object. Hiểu rõ các paradigm sẽ giúp bạn không áp dụng chúng một cách cứng nhắc, nhờ đó lời giải của bạn cho các vấn đề sẽ trở nên uyển chuyển, hiệu quả hơn (ví dụ này có vẻ hợp). Cũng bởi vì không có paradigm nào là tốt nhất, các ngôn ngữ lập trình hiện đại thường đều ở dạng multiparadigm, trong đó có thể có một paradigm là chủ đạo. Muốn tận dụng tốt nhất các tính năng mà những ngôn ngữ này cung cấp, bạn phải hiểu thế mạnh của các paradigm và nhìn ra những tình huống ứng dụng chúng.

Cuối cùng, cũng như nhiều khái niệm khác như khoa học máy tính và kĩ thuật máy tính, strong typing và weak typing, các paradigm nói chung cũng được định nghĩa một cách tương đối hoặc trừu tượng. Cho nên có thể đôi khi bạn sẽ băn khoăn liệu paradigm này có phải là hệ quả hay là một cách phát biểu khác của paradigm kia, v.v. Trong trường hợp này có lẽ tốt nhất là khỏi phải suy nghĩ nhiều làm gì cho mệt :D, thế giới định nghĩa sao ta chấp nhận thế. Điều quan trọng khi tìm hiểu một paradigm là phải ghi nhớ những khái niệm quan trọng nhất mà nó cổ vũ, và nếu có thể thì học một ngôn ngữ được cho là đại diện cho nó. Như thế, khi đã thành thạo, cho dù bạn không thể định nghĩa paradigm này, bạn vẫn có thể rút ra được những phương pháp và kinh nghiệm hay cho việc lập trình. Có thể xem như paradigm tác động tới thiết kế của các ngôn ngữ, và ngược lại các ngôn ngữ góp phần định nghĩa nên paradigm.