The Quality of Software Design
Bài đăng này đã không được cập nhật trong 3 năm
More Play in the Utopia of reuse
"Hãy xem kỹ USDollar class và VndBill class! Sau đó thử nghĩ xem có thể làm đơn giản hơn nữa được không ?” là bài tập về nhà trong kỳ trước. Nhớ lại xa hơn chút nữa thì thấy trong nửa cuối của bài viết, tôi đã tạo class có thể sử dụng cả đồng USD và đồng xu cent bằng cách thêm String format dùng để hiển thị lên constructor của USDollar class và biến số denom dùng cho mệnh giá tiền nhỏ.
4: USDollar(unsigned long val, unsigned long cnt,
5: string format, unsigned long denom) {
6: this.value = val;
7: this.count = cnt;
8: this.printformat = format;
9: this.printdenom = denom;
10: }
Tôi nghĩ rằng có nhiều bạn còn chưa biết bây giờ “nên đơn giản hóa chỗ nào trong đoạn code này và đơn giản hóa như thế nào?”. Việc này các bạn sẽ biết khi đã tích lũy được kinh nghiệm nên tôi muốn chúng ta không nên quá băn khoăn về vấn đề này. Đầu tiên, điều tôi muốn các bạn chú ý là thực hiện thay đổi này “để làm gì?”. Tất nhiên mục đích là “để xử lý cả đồng USD và đồng cent trong cùng 1 class”. Việc xử lý mà không cần phân biệt đồng USD và cent chính là việc làm trừu tượng hóa và đơn giản hóa code. Như vậy, thêm một bước cao hơn, nếu chúng ta xử lý cho VND và USD trong cùng 1 class thì sao? Như vậy hẳn là còn có thể trừu tượng hóa và đơn giản hóa hơn nữa phải không? Điểm trọng yếu của bài viết kỳ này chính là nội dung đó.
Để generate instance của USDollar thì sẽ mô tả như sau:
bill = new USDollar(1000, 2, “$ %d : %d”, 100);
Sau đây, hãy thử vẽ lại cấu trúc class một lần nữa.
Phương pháp chỉ định khi generate format hiển thị tên đồng tiền này có thể áp dụng không chỉ cho đồng USD mà còn cho cả VND. Chỉ việc set format string thành ”%d vnd %d” là xong. Vậy, chúng ta hãy thử set như vậy cho contractor của VndBill:
4: VndBill(unsigned long val, unsigned long cnt,
5: string format, unsigned long denom) {
6: this.value = val;
7: this.count = cnt;
8: this.printformat = format;
9: this.printdenom = denom;
10: }
Bản thân đoạn code method này là giống hệt với code constractor của USDollar. (Thực ra tôi đã copy & paste nội dung, chỉ thay mỗi tên thôi.) Nếu là method hoạt động bằng nội dung code giống hệt nhau giữa VndBill và USDollar thì code thành contractor của Bill class của upper class là đúng.
4: Bill(unsigned long val, unsigned long cnt,
5: string format, unsigned long denom) {
6: this.value = val;
7: this.count = cnt;
8: this.printformat = format;
9: this.printdenom = denom;
10: }
Trong trường hợp này việc mô tả contractor của USDollar và VndBill như thế nào phải phụ thuộc vào ngôn ngữ và loại xử lý. Trường hợp ngôn ngữ lập trình là C++:
4: USDollar(unsigned long val, unsigned long cnt,
5: string format, unsigned long denom)
6: : Bill(val, cnt, format, denom)
7: {
8: }
Nếu làm như thế này thì có thể chỉ rõ việc gọi contractor của super class. Mấu chốt là cho dù dùng ngôn ngữ lập trình nào hay với bất kỳ xử lý nào cũng không viết lặp lại cùng một nội dung code ở những chỗ khác nhau. Như tôi đã nói nhiều lần trước đây, cần phải tạo thói quen cho bản thân rằng trước khi thực hiện copy & paste, luôn luôn phải tự hỏi xem có nhất thiết phải copy & paste hay không? Có thể code đơn giản hơn nữa không?
Cụ thể thì method có cần thiết thay đổi print method thành VndBill và USDollar hay không nhỉ?
11: void print() {
12: printf(this.printformat, this.value/this.denom, this.count);
13: }
Trước khi xem câu trả lời, tôi muốn các bạn tự mình suy nghĩ. Ở đây ta sẽ dùng cùng 1 method cho VndBill và USDollar. Vì vậy nên có lẽ code chung cả phần này vào Bill là được.
Tại thời điểm này, sơ đồ của class sẽ như bên dưới. Tập trung hầu hết nội dung code vào Bill thì VndBill và USDollar chỉ chứa constructor gọi super class thôi.
Vậy, tiếp theo thì vấn đề là có cần thiết phải định nghĩa VndBill và USDollar thành subclass hay không? Điểm thay đổi từ đầu cho đến đoạn này là VND. USD và cent thì tất cả đều đã có thể code trong cùng Bill class rồi. Nếu như vậy thì không cần subclass VndBill và USDollar nữa đúng không nhỉ? Việc “code chức năng này như thế nào” không còn là vấn đề code của riêng một class nào nữa mà nó đã trở thành vấn đề “thiết kế cấu trúc của application như thế nào”.
20: list<VndBill*> m_vnd;
21: list<USDollar*> m_usd;
Nếu định nghĩa list với tư cách từng class riêng biệt như thế này
20: list<Bill*> m_vnd;
21: list<Bill*> m_usd;
Hay định nghĩa chung vào 1 class như thế này thì chỉ là chuyện thực hiện theo cách nào thì dễ xử lý hơn thôi. Method được lấy làm subclass rút cuộc là gì? Dù nói là method nhưng hẳn là các bạn cũng chưa thể hình dung ra ngay đúng không? Trong những trường hợp không biết cách nào tốt hơn như thế này thì “Simple is best”. Đầu tiên ta nên chọn cách đơn giản. Trong trường hợp này thì “Không tạo subclass” là đơn giản nên hãy code tất cả trong Bill. Sau đó nếu phát sinh vấn đề gì thì tạo subclass cho chỗ đó cũng được.
Vậy, khi không tạo subclass mà tạo object cho USD và VND trong Bill class thì method tính tổng số tiền sẽ thành như thế nào?
20: unsigned long getsumDollar() {
21: unsigned long sum = 0;
22: for (Bill b: m_usd) {
23: sum += b.value*b.count;
24: }
25: return sum;
26: }
27: void printDollarSum(unsigned long sum) {
28: printf(“USD $ %d.%d”, sum / 100, sum %100);
29: }
30: unsigned long getsumVnd() {
31: unsigned long sum = 0;
32: for (Bill b: m_vnd) {
33: sum += b.value*b.count;
34: }
35: return sum;
36: }
37: void printVndSum(unsigned long sum) {
38: printf(“VND %d vnd”, sum);
39: }
Đoạn code này đã hiển thị được tổng số tiền USD và VND theo như yêu cầu. Nhưng dĩ nhiên là ngay cả ở đây chúng ta cũng phải xem lại về trừu tượng hóa và đơn giản hóa. Các bạn có thể thấy đoạn code từ dòng 21~26 và đoạn từ dòng 31~36 hầu như là giống nhau. Nếu các bạn nhìn thấy đoạn code như thế này từ trước thì hẳn các bạn cũng sẽ đau đầu tìm cách làm nó trở nên “đẹp” hơn phải không.
20: unsigned long getsumBill(list<Bill*> billist) {
21: unsigned long sum = 0;
22: for (Bill b: billlist) {
23: sum += b.value*b.count;
24: }
25: return sum;
26: }
27: unsigned long getsumDollar() {
28: return getsumBill(m_usd);
29: }
30: unsigned long getsumVnd() {
31: return getsumBill(m_vnd);
32: }
Như thế này thì nhìn chỉ khác đi một chút thôi nhưng tôi muốn các bạn hiểu rằng: nếu duy trì việc chú ý đến những khác biệt nhỏ thì sẽ có thể tạo nên những thay đổi lớn lao về chất lượng chương trình mà các bạn code.
Việc “hiển thị” tổng số tiền thì chúng ta hãy xét sau. Còn bây giờ, hãy cùng xem lại xem chúng ta đã code như thế nào.
1: class Bill {
2: unsigned long value;
3: unsigned long count;
4: string printformat;
5: unsigned long printdenom;
6: public:
7: Bill(unsigned long val, unsigned long cnt,
8: string format, unsigned long denom) {
9: this.value = val;
10: this.count = cnt;
11: this.printformat = format;
12: this.printdenom = denom;
13: };
14: ~Bill() {};
15: void print() {
16: printf(this.printformat, this.value/this.denom, this.count);
17: }
18: }
19: :
20: unsigned long getsumBill(list<Bill*> billist) {
21: unsigned long sum = 0;
22: for (Bill b: billlist) {
23: sum += b.value*b.count;
24: }
25: return sum;
26: }
27: unsigned long getsumDollar() {
28: return getsumBill(m_usd);
29: }
30: unsigned long getsumVnd() {
31: return getsumBill(m_vnd);
32: }
Trên đây là nội dung mà chúng ta đã code được từ đầu đến giờ. Nếu xử lý đoạn code này như sau:
bill1 = new Bill(1000, 2, “$ %d : %d”, 100);
bill2 = new Bill(10, 3, “%d cent : %d”, 1);
bill3 = new Bill(500000, 2, “%d vnd : %d”, 1);
Ta có thể set 2 tờ 10 USD, 3 đồng xu 10 cent, 2 tờ 500,000VND thành instance. Nếu insert Bill 1, Bill 2 vào list m_usd, Insert Bill3 vào list m_vnd thì cũng có thể tính được tổng số tiền. Các bạn thấy thế nào? Các bạn không nghĩ là code đã trở nên đơn giản hơn nhiều rồi sao?
Hơn nữa, thực ra trong những item bị NG trước đây thì “(NG-2) Hiển thị đơn vị tiền của quốc gia tùy ý” đã được giải quyết trong trạng thái này rồi. (Hãy tham khảo nội dung "Việc chưa làm được" ở bài viết kỳ trước.)
Nếu là 2 tờ 1000 JPY thì hãy thử code là:
bill4 = new Bill(1000, 2, “ %d Yen : %d”, 1);
”1000 Yen : 2” sẽ được hiển thị.
Nếu là 5 tờ 50 EUR thì code như sau:
bill5 = new Bill(5000, 5, “€ %d : %d”, 100);
Nếu là 10 tờ 1000 somali shilling thì code như sau:
bill6 = new Bill(1000, 10, “%d SOS : %d, 1);
Chẳng phải chúng ta đã thể hiện được các loại tiền tệ trên thế giới một cách “đẹp đẽ” hay sao!
Nếu tiếp tục code với cấu trúc riêng từng class cho VND, USD, cent và phụ thuộc vào context của từng loại tiền tệ như lúc đầu để thực hiện yêu cầu “Hiển thị đơn vị tiền của quốc gia bất kỳ”, chúng ta sẽ phải tạo class cho từng loại tiền tệ của các quốc gia trên toàn thế giới. Nhưng nếu ta trừu tượng hóa cơ chế đó, đơn giản hóa thiết kế class và thống nhất class của VND và USD thì kết quả là có thể thực hiện hiển thị các loại tiền tệ trên thế giới chỉ trong mấy chục dòng code.
Quan trọng hơn nữa là việc trừu tượng hóa, đơn giản hóa thiết kế đã mang lại hiệu quả sau:
-
Số bug giảm. Code càng đơn giản thì tỉ lệ bug phát sinh càng thấp. Đây là chuyện đương nhiên nhưng trong trường hợp này lại vô cùng quan trọng. Code được đơn giản hóa sẽ có chất lượng cao hơn. Việc copy & paste rồi chỉnh sửa một chút đoạn code đã copy, mở rộng chức năng và làm phức tạp logic code lên thì ai cũng có thể dễ dàng làm được nhưng việc trừu tượng hóa và đơn giản hóa code thì cần các bạn phải nỗ lực mới thực hiện được.
-
Nâng cao perfomance Nói chung, code có logic đơn giản thì tốc độ xử lý sẽ nhanh hơn. Không chỉ đạt được tốc độ xử lý nhanh do ít phân nhánh điều kiện mà logic code đơn giản khiến dự kiến về phân nhánh trên microprocessor của CPU khó bị sai nên có thể sử dụng CPU cache một cách hiệu quả, có khi có thể tăng tốc độ xử lý đến mấy lần.
-
Code dễ đọc hơn Code ngắn, code đơn giản thì dễ đọc hơn và dễ phát hiện chỗ sai hơn.
-
Phạm vi áp dụng rộng hơn Như các bạn biết ngay cả trong ví dụ lần này thì phạm vi áp dụng đã rộng hơn và reusability cũng cao hơn. Không phải chỉ sử dụng lại code cũ với bất kỳ nội dung gì là reuse. Việc viết ra đoạn code dễ reuse cũng rất quan trọng. Việc trừu tượng hóa một cách thích hợp và đơn giản hóa code, xét ở một khía cạnh khác cũng có thể coi là đang thực hiện reusability.
Từ số thứ 2 trong loạt bài dài kỳ này chúng ta cũng đã đưa ra vấn đề là "Suy nghĩ về reuse" và xem xét rất nhiều thiết kế. Trong đó, có lẽ cũng có bạn cảm thấy “Không phải là reuse hay sao?” Nhưng tôi muốn các bạn nhớ lại code khi chúng ta mới học về C/C++.
printf( “Hello world!”);
Thư viện C tiêu chuẩn có chứa method printf này suy cho cùng cũng là thư viện được reuse. Không chỉ có printf, tất cả những hàm số thư viện tiêu chuẩn khác như strcpy, fopen, sqrt… cuối cùng cũng đều có những interface đã được trừu tượng hóa, đơn giản hóa. Tôi nghĩ rằng cũng có bạn băn khoăn về việc “Tại sao lại phải set argument thành như vậy nhỉ?”. Bởi vì đó là kết quả có được sau khi đã suy nghĩ để có thể reuse đối với những requirement đa dạng trên thế giới.
Thông thường khi bạn tạo chương trình cho riêng mình thì không cần thiết phải trừu tượng hóa method đó đến mức như vậy. Nhưng chỉ cần để ý một chút về yếu tố trừu tượng hóa và đơn giản hóa thì các bạn sẽ biết được nhiều source code phức tạp mà chúng ta đã viết từ trước tới giờ đã không quan tâm đến yếu tố reusability như thế nào. Nếu dần dần thay đổi từng chút về điểm này thì chất lượng code của bạn sẽ được cải thiện rõ rệt. Từ nay trở đi, tôi muốn các bạn luôn nghĩ đến điểm này khi design hay coding.
À, tôi quên mấy chức năng “Hiển thị tổng số tiền”. Đây là bài tập về nhà cho các bạn. Các bạn hãy nghĩ xem làm thế nào để có thể thực hiện được chức năng hiển thị tổng số tiền được trừu tượng hóa và có tính “reuable”. Nếu các bạn hiểu rõ nội dung bài báo từ đầu đến giờ thì tôi nghĩ bài tập này hẳn là không khó.
Hiroki Narita
All rights reserved