12/08/2018, 14:09

The Quality of Software Design ~kỳ 3~

Part 3. Journey to the Utopia(*) of reuse Bài viết trong số trước hơi nhiều chữ nên có lẽ có nhiều bạn cảm thấy khó đọc. Lần này, chúng ta hãy cùng một lần nữa suy nghĩ về "reuse" thông qua nhiều bài tập nhé.  Đầu tiên là phần bài tập. Chúng ta hãy cùng xem xét "Chương trình hiển thị số tiền ...

Part 3. Journey to the Utopia(*) of reuse

Bài viết trong số trước hơi nhiều chữ nên có lẽ có nhiều bạn cảm thấy khó đọc. Lần này, chúng ta hãy cùng một lần nữa suy nghĩ về "reuse" thông qua nhiều bài tập nhé.  Đầu tiên là phần bài tập. Chúng ta hãy cùng xem xét "Chương trình hiển thị số tiền trong ví của bạn theo số tờ được chia theo từng loại". Trong đơn vị tiền tệ Việt Nam đồng đang lưu hành hiện nay, có các loại mệnh giá tiền từ 500,000 đồng đến 1,000 đồng. (Tôi đã từng nhìn thấy tờ 200 đồng rồi, nhưng từ trước đến nay, tôi mới nhìn thấy tờ tiền mệnh giá đó đúng một lần. Tờ 500 đồng thì tôi từng được trả lại khi mua hàng ở VinMart. Nhưng nói thực lòng thì ngoài Vinmart ra, tôi chưa từng nhận được tờ tiền mệnh giá đó ở nơi nào khác và cũng chưa từng sử dụng đến tiền mệnh giá này nên ở đây, chúng ta hãy tạm thời quên đi hai loại mệnh giá này nhé.) Output mong muốn của chương trình có dạng như dưới đây:

>
> 500,000vnd : 2
>
> 200,000vnd : 0
>
> 100,000vnd : 4
>
>  50,000vnd : 2
>
>  20,000vnd : 3
>
>  10,000vnd : 4
>
>   5,000vnd : 1
>
>   2,000vnd : 6
>
> 1,000vnd : 3

Nếu muốn hiển thị được như vậy, ta hãy cùng suy nghĩ cách thức design để thực hiện phần mềm này. Lần này tôi sẽ viết một mã giả (Pseudo code) giống như C++, giống như Java nên các bạn hãy làm quen dần với nó nhé.

 1: class MyWallet {

 2:     int bill500000 = 2;

 3:     int bill200000 = 0;

 5:     int bill1000 = 3;

 6:     public void printbill()
            {

 8:             printf(“500,000vnd : %d”, bill500000);

 9:             printf(“200,000vnd : %d”, bill200000);

11:             printf(“   1,000vnd : %d”, bill1000);
            }

Chúng ta hãy cùng bắt đầu từ chỗ đơn giản nhất nhưng code chưa được tốt trong ví dụ này. Ở đây, thực ra đang code theo đúng y như những gì được bảo. Hơn nữa còn rất dễ hiểu. Chỉ là lưu các counter số tờ tiền của từng loại dưới dạng biến số rồi cho hiển thị chúng lần lượt bằng printf. Ngay cả những người mới chỉ viết được chương trình dạng "Hello world" cũng có thể dễ dàng hiểu được việc mà chương trình này đang thực hiện nhỉ! Việc viết ra được một "chương trình đơn giản để ai cũng có thể hiểu được" thực ra là một việc rất tuyệt, là lý tưởng mà tôi muốn tất cả programmer cần luôn tâm niệm.  Tuy nhiên, chính vì bản thân công việc mà đoạn code trong ví dụ này thực hiện khá đơn giản nên nó mới dễ hiểu và đơn giản. Nếu xét trong thực tế, các solution (program) tương đối phức tạp, khi đó phương pháp thực hiện này trở nên rất "không tốt" về mặt thiết kế.  Giả sử, yêu cầu cho chương trình này là "Dù thế nào cũng muốn biết tổng số tiền". Vì vậy sẽ cho tổng số tiền hiển thị ở dòng cuối. Yêu cầu là: muốn cho hiển thị bên dưới dòng 1,000vnd "output mong muốn" đầu tiên.

    1,000vnd : 3

    total : 1,620,000vnd

Vậy, nên thay đổi code như thế nào thì tốt nhỉ? Đầu tiên, tôi nghĩ các bạn đã biết ngay là cần phải có phần thực hiện tính toán tổng số tiền. Thêm nữa, nếu là những bạn đã đọc bài viết số lần trước rồi thì sẽ nhớ đến việc "nếu đặt việc tính toán tổng số tiền sang một method riêng thì tốt hơn". (Tôi rất mong các bạn có thể nhớ ra!)

15:    unsigned long getsum() {
16:           unsigned long sum
17:                  = 500000*bill500000
18:                  + 200000*bill200000
19:                  + 100000*bill100000
20:           :
21:                  + 1000*bill1000;
22:           return sum;
23:     }

Sau khi thực hiện như trên thì insert vào cuối 1 line như sau:

11:            printf(“   1,000vnd : %d”, bill1000);
12:            printf(“    total : %dvnd”, getsum());

Như vậy có được không? Nếu các bạn nhớ ra được là "Không, không! Đợi chút! Trong bài báo trước mình đã đọc là những trường hợp như thế này có khả năng xảy ra overflow" là một việc vô cùng đáng mừng đối với tôi. Theo đó, nếu vượt quá 4.2 tỉ vnd (unsigned 32bit) hay 2.1 tỉ vnd (signed) thì có khả năng chương trình sẽ chạy không đúng. Tuy nhiên, rất tiếc trọng tâm của bài viết lần này không phải tập trung vào điều đó. Việc tôi muốn các bạn chú ý đến là ”cohesion”. "Khái niệm "cohension" mới mẻ gần đây đã trở nên quen thuộc" là minh chứng cho mối quan hệ giữa cohension và design đang dần trở nên sâu sắc. Hiện nay, dù cho bản thân khái niệm "cohesion" còn chưa được nắm bắt một cách rõ ràng nhưng ắt hẳn sẽ đến một ngày ta chợt ngộ ra ý nghĩa của nó nên các bạn đừng nóng vội mà hãy tiếp tục học hỏi.

Xin trở lại về sample code. Chúng ta hãy cùng giả định rằng một ngày nào đó, Chính Phủ Việt Nam bỗng nhiên phát biểu rằng: "Chúng tôi sẽ phát hành tờ tiền mệnh giá 1,000,000 vnd". Vậy khi ấy, chương trình này sẽ cần phải thay đổi như thế nào?

 Đầu tiên cần phải thêm member của class.
     2:           int bill1000000 = 0;

Thêm nữa, bắt buộc phải làm cho method hiển thị printbill có thể hiển thị được giá trị 1,000,000 vnd. Vậy thì sẽ thành như sau:

 7:     void printbill() {
 8:             printf(“1,000,000vnd : %d”, bill1000000);
 9:             printf(“500,000vnd : %d”, bill500000);

Hơn nữa, cần thay đổi cả method tính toán tổng số tiền.

15:    unsigned long getsum() {
16:           unsigned long sum
17:                  = 1000000*bill1000000
18:                  + 500000*bill500000

Do trước đã chỉnh sửa chỗ này chỗ kia làm cho vị trí các line code bị lệch đi nên hãy chú ý nhé.  Điều tôi muốn các bạn chú ý ở đây là để "thêm tờ 1,000,000 vnd" thì cần phải sửa 3 chỗ. Ngoài ra, hiện nay mới chỉ có 3 chỗ cần sửa thôi, nhưng trường hợp nếu design giống như sample code này thì cần phải xem lại toàn bộ code xem có chỗ nào khác cần phải sửa không. Vì đây chính là vấn đề lớn nhất dẫn đến việc code có cohesion thấp. Việc chỉnh sửa 1 yếu tố " thêm tờ 1,000,000 vnd" mà ở giai đoạn hiện nay thôi đã phải sửa tận 3 chỗ. Ngược lại, design có cohension cao đòi hỏi phải tập hợp những nội dung cần sửa lại một chỗ. Vậy, trên thực tế cần phải làm gì để cải thiện cohension?

Đầu tiên, quan trọng là phải trừu tượng hóa tờ tiền lên. À mà các bạn có hiểu ý tôi nói gì không nhỉ?
2:     int bill500000 = 2;

Chúng ta hãy cùng nghĩ xem dòng code trên muốn thể hiện điều gì.  Trường hợp coder cần phải biết ý nghĩa của các giá trị của biến số hay instance của class và phải phân biệt được thì có thể coi là "có độ trừu tượng thấp". Cụ thể ở đây, coder cần viết chương trình dựa trên cơ sở đã hiểu việc "bill500000 là số tờ tiền mệnh giá 500,000". Khi bạn đọc dòng code này, có lẽ bạn có thể hiểu được việc "bill500000 là số tờ tiền mệnh giá 500,000" nhưng đối với máy tính thì nó chỉ hiểu đó là thông tin "giá trị của [cái gì đó] là 2" mà thôi. Chúng ta hãy cùng nhớ lại dòng code đầu của method getsum():

15:    unsigned long getsum() {
16:           unsigned long sum
17:                  = 500000*bill500000

“500000*bill500000” đã chứng minh rằng coder cần phải hiểu "mệnh giá được thể hiện bằng bill500000 là 500,000vnd" rồi truyền đạt lại cho máy tính. Dù cho bạn có biết "tên biến" nhưng đối với máy tính, tên biến chẳng qua chỉ là một cái tag thôi. Trên phương diện của máy tính, nó chỉ nhận biết ý nghĩa của "dạng" và "giá trị" mà thôi.  Tóm lại, máy tính chỉ cần nhận biết cặp thông tin [Là tờ 500,000 đồng] và [Số tờ tiền] là được. Giải thích bằng từ ngữ có lẽ sẽ hơi khó hiểu nhưng các bạn chỉ cần xem đoạn sample code này là có thể hiểu ngay.

 1: class Bill {
 2:      Bill(unsigned long v, unsigned long c) { value = v; count = c };
 3:      unsigned long value;
 4:      unsigned long count;
 5: }

Trong Bill class đang lưu giá trị của đồng tiền (=value) và số tờ tiền (=count) nên nếu như giá trị tiền là 500,000vnd có 2 tờ thì chỉ cần thể hiện là " value=500000; count=2" là được. Về object thì generate instance bằng bill = new Bill(500000, 2);  Theo đó, toàn bộ giá trị tiền đều generate tương tự, lưu vào mảng ”billarray” và cùng thay đổi cả printbill() và getsum().

 6:    Bill billarray[] = {
 7:               Bill(500000, 2),
 8:               Bill(200000, 0),
 9:                   :
10:              Bill(1000, 3)
11:             };
12:   public:
13:     void printbill() {
14:            for (Bill b: billarray) {
15:              printf(“%dvnd : %d”, b.value, b.count);
16:           }
17:           printf(“    total : %dvnd”, getsum());
18:     }
19:    unsigned long getsum() {
20:           unsigned long sum = 0;
21:           for (Bill b: billarray) {
22:                 sum += b.value*b.count;
23:           }
24:           return sum;
25:     }

Với đoạn code mới, chắc các bạn cũng hiểu kể cả trong trường hợp tôi giả định khi nãy "Tờ 1,000,000 vnd được phát hành" thì chỉ cần thêm vào mảng yếu tố 1,000,000 vnd là tất cả sẽ hoạt động mà không cần phải thay đổi printbill, getsum nhỉ? Tóm lại, với biến này, sự khác nhau giữa từng loại mệnh giá tiền đã được bao hàm trong VndBill nên các method trong MyWallet sẽ được độc lập, không chịu ảnh hưởng bởi sự khác biệt kia. Nói một cách khác thì đây là một ví dụ về code "có cohension cao". Không chỉ là tờ 1,000,000 vnd mà ngay cả khi thực hiện thay đổi tỷ giá cho vnd thì cũng sẽ không cần thay đổi những method này. Cùng quay trở lại câu chuyện về chuyên môn, class Bill này đã trừu tượng hóa thông tin tổ hợp của "mệnh giá tiền" và "số tờ", coder đã được giải phóng, không cần phải viết chương trình phụ thuộc vào việc nắm được biến số và cách sử dụng của chúng nữa.  Vậy, hãy cùng tiến thêm 1 bước. Thực ra trong ví của bạn không chỉ có Việt Nam đồng mà còn có cả USD nữa! Vậy cần phải làm sao? Hiện nay, các mệnh giá đồng USD đang được lưu hành gồm có: tiền giấy: $100,$50,$20,$10,$5,$1 và tiền xu: 50c,25c,10c,5c,1c. Thực ra cũng có cả đồng xu $1 nữa nhưng hầu như đồng tiền đó không còn lưu hành mấy nên chúng ta hãy quên nó đi.  Nếu tạo giá trị "đồng $10 có 2 tờ" thành instance và xử lý là b = new Bill(10,2); thì sẽ ra sao? Đương nhiên máy tính sẽ hiểu thành "có 2 tờ 10 đồng". Cho nên mặc dù bạn có tận $$0 nhưng đến trà cũng không uống được (vì máy tính hiểu là bạn chỉ có 20 vnd)! Vậy, trường hợp "có 3 xu 10c" thì như thế nào? Nếu code thành b = new Bill(10,3); thì máy tính sẽ lại hiểu thành "có 3 tờ 10 đồng". Cuối cùng không hiểu cái gì ra cái gì luôn!  Đầu tiên, cần phải tưởng tượng được việc phải phân biệt vnd, usd và cent. Tuy nhiên, "sự khác biệt giữa vnd và USD" và "sự khác biệt giữa USD và cent" đối với những loại tiền khác nhau sẽ không giống nhau. Khi tính tổng số tiền USD thì việc hiển thị số tiền có cả cent là điều hết sức bình thường. Nhưng đồng xu cent thì cứ 100 cent bằng giá trị 1 USD. Vậy, nên làm thế nào mới ổn?  Ví dụ, tôi thực hiện thiết kế class như dưới đây.

Bây giờ chắc có bạn sẽ nghĩ là "Không tinh tế gì cả". Những bạn đó chắc đã hiểu gần hết rồi. Bạn sẽ nghĩ rằng:"Do các biến thành viên đều như nhau nên cứ tạo Super class rồi tạo các subclass là Vnd hay Dollar có phải hơn không?" Vốn dĩ đều coi những nội dung đó là cùng 1 frame nên chỉ cần tạo subclass cho từng loại đơn vị tiền tệ là được. Hơn nữa, hình như những method nội bộ cũng có thể common hóa được.

Việc reuse (cuối cùng cũng xuất hiện rồi!) biến số hay method thì tốt lúc khởi đầu nhưng thực ra xét về độ trừu tượng thì xử lý như vậy độ trừu tượng không cao. Bởi vì khi phải xử lý với 3 loại đơn vị tiền tệ là Vnd, Dollar và cent thì coder phải ý thức được sự khác nhau của các class khi code. Ví dụ: chúng ta hãy thử nghĩ xem khi tính toán "Tổng số dollar" thì như thế nào? Trường hợp thiết kế class này thì có lẽ sẽ tính toán như bên dưới. Ở đây, thông tin mệnh giá dollar được lưu trong dollararray, thông tin đồng xu cent thì lưu trong centarray.

13:    unsigned long getsumDollar() {
14:           unsigned long centsum = 0;
15:           for (CentCoin c: centarray) {
16:                 centsum += c.value*c.count;
17:           }
18:           unsigned long dollarsum = 0;
19:           for (DollarBill d: dollararray) {
20:                 dollarsum += d.value*d.count;
21:           }
22:           return dollarsum + centsum/100;
23:     }

Vấn đề lớn nhất của design này là phải design/code chương trình cho context "Dù cho đồng Dollar có cả các mệnh giá nhỏ là cent thì cũng có rule quy đổi là 100 cent = 1 dollar". Việc phải viết chương trình cho từng loại đơn vị tiền nào tương ứng với hệ thống như thế nào là vô cùng phiền phức.

Hơn nữa, nếu yêu cầu là "tổng số USD' thì có thể bỏ phần thập phân (chưa đủ 1 dollar) đi không? Việc này tùy theo chương trình nhưng việc cho phép bỏ đi phần thập phân khi tính toán số tiền chỉ giới hạn trong trường hợp điều này được định nghĩa rõ ràng trong Requirement.  Ah, đúng rồi! Tôi đã nghĩ ra một cách làm tốt rồi! Chỉ cần set kiểu của method thành floating point là được!

13:    double getsumDollar() {
14:           unsigned long centsum = 0;
15:           for (CentCoin c: centarray) {
16:                 centsum += c.value*c.count;
17:           }
18:           unsigned long dollarsum = 0;
19:           for (DollarBill d: dollararray) {
20:                 dollarsum += d.value*d.count;
21:           }
22:           return ((double)centsum / 100.0 + (double)dollarsum);
23:     }

Yeah! Vậy là có thể quay về khoản tiền tổng của cent và dollar bằng cách này rồi!

Uhm, chờ một chút. Không biết có thể tùy tiện sửa method thống kê số tiền tổng từ kiểu unsigned long sang kiểu double không nhỉ? Khi sửa như thế này thì kiểu của method "tổng số tiền vnd" sẽ như thế nào?  Vậy nếu có cả tiền Yên Nhật thì sẽ thế nào? Yên Nhật là đơn vị tiền tệ không sử dụng mệnh giá tiền dưới 1 Yên và là "loại đồng" nên code bằng kiểu unsigned long chắc là được? Nếu là đồng Euro thì sao? Euro là "kiểu dollar"? Thế thì sẽ thành design quá phụ thuộc vào context. Hoặc là cũng có cách đổi kiểu giá trị trả về của tất cả các loại tiền tệ thành double. Dù dùng cách đó thì cũng chỉ có thể nói rằng design đó vẫn rất phụ thuộc vào context khi có loại tiền tệ cần biểu hiện số tiền bằng phần thập phân sau dấu phẩy và có loại thì không.

Như tôi đã viết trong phần đầu của bài báo này "Trường hợp, coder phải hiểu và phải phân biệt được ý nghĩa của giá trị biến số được lưu trong instance của biến hoặc class" là không đủ độ trừu tượng. Nếu thiết kế code cho vnd, dollar và cent như ví dụ trước thì chính là việc coder phải nắm được "ý nghĩa của class" của từng loại tiền tệ và phân biệt được chúng. Như vậy, về mặt thiết kế sẽ rơi vào tình trạng vô cùng low cohension.

Vậy, làm thế nào để design phần này trở nên high cohension? Tôi xin giành điều này làm bài tập đến số tạp chí sau. Gợi ý đầu tiên là "Design của class này cơ bản là không đạt". Bạn nào đã biết câu trả lời hoặc bạn nào hoàn toàn chưa biết gì về câu trả lời và muốn có thêm gợi ý hãy gửi email về ban biên tập tạp chí nhé!  Xin hẹn gặp lại các bạn trong số phát hành lần sau.

(*) Utopia: một vùng đất tưởng tượng, tại đó mọi thứ đều hoàn hảo. Từ này được đưa ra sử dụng lần đầu tiên trong cuốn sách Utopia (1516) viết bởi Sir Thomas More.

0