Khái quát về các Programming Paradisms

Paradigm: hệ tiên đề, mô hình, mẫu hình, nề nếp dạng thức để suy nghĩ trong một khuôn khổ thực nghiệm khoa học, hay những ngữ cảnh khác của tri thức (chẳng hạn như lập trình). Trong bài viết này, paradigm được dịch thành “phương pháp”, lưu ý để đồng bộ thuật ngữ.

Structured Programming

Trước lập trình có cấu trúc, các lập trình viên gần như không có giới hạn nào trong việc điều khiển máy tính, họ có thể điều hướng luồng thực thi của máy tính đến bất kỳ dòng nào trong chương trình một cách hoàn toàn tự do, hay chính xác hơn là vô tổ chức. Chúng ta viết chương trình từ trên xuống, nhưng luồng thực thi thì có thể được chuyển tới bất cứ vị trí nào trong chương trình. Chúng ta gọi đó là trực tiếp chuyển dịch luồng điều khiển.

Việc này khiến chương trình gần như mất khả năng hoạt động như tưởng tượng, nó hay lỗi, theo những cách rất ú òa. Điều này dẫn tới một suy nghĩ: có thể nào khiến cho việc lập trình cũng có tính đúng đắn chứng minh được như khi chúng ta chứng minh các phương trình toán học hay không. Chúng ta có thể nào khiến cho chương trình có đặc tính của một phép chứng minh toán học, tại đó nó sử dụng các định đề, định lý, các suy luận, các hệ quả, bổ đề… để dẫn chương trình đến với một kết quả mà chúng ta hoàn toàn có thể suy luận được là đúng hay sai một cách chắc chắn, hay không?

Để làm được điều đó, các hình thái điều khiển luồng thực thi của chương trình đã được đặt lên bàn cân, nhằm mục đích tìm ra đâu là những đơn vị suy luận tối thiểu mà chúng ta có thể dựa vào để cấu thành nên những phép chứng minh đồ sộ hơn – mà không phải lo lắng đến những sai sót bất ngờ.

Đây là lúc những cấu trúc điều khiển cơ bản của một chương trình, mà tất cả các lập trình viên của tất cả các ngôn ngữ lập trình hiện đại trên thế giới đều đang áp dụng hàng ngày: chúng bao gồm cấu trúc tuần tự, cấu trúc lựa chọn, và cấu trúc lặp.

Các lập trình viên của thế kỷ 20 chắc sẽ không nhận thấy điều gì đặc biệt ở ba cấu trúc căn bản trên. Điều đặc biệt của lập trình có cấu trúc không nằm ở những cấu trúc nó có, mà nằm ở những gì nó không có: câu lệnh GOTO.

Phải, GOTO là một lệnh điều khiển bắt buộc mà mọi chương trình đều phải có. Máy tính thực hiện xong một câu lệnh và rồi GOTO câu lệnh tiếp theo, đó là cấu trúc tuần tự. Máy tính bỏ qua một vài câu lệnh và thay vào đó GOTO tới một câu lệnh khác, dựa trên một điều kiện luận lý, đó là cấu trúc lựa chọn. Máy tính GOTO về quá khứ và thực hiện lại một câu lệnh nó đã từng thực hiện, đó là cấu trúc lặp. Những cách sử dụng lệnh GOTO này, nếu được áp dụng một cách kỷ luật, sẽ không gây ra ngoại lệ khó lường trước.

Nhưng ngoài ba cách sử dụng kể trên, sự tồn tại của lệnh GOTO còn tạo cơ hội cho lập trình viên sáng tạo ra vô số cấu trúc điều khiển trời ơi đất hỡi khác, và đó chính là nguồn cơn của những lỗi không đoán được. Qua sự thực đó, phương pháp lập trình có cấu trúc đã đi đến việc biến các cấu trúc sử dụng lệnh GOTO an toàn thành những cấu trúc nguyên thủy của chương trình, bên cạnh đó, lệnh GOTO được giấu đi trước mắt lập trình viên, nhằm hạn chế đi khả năng dịch chuyển luồng điều khiển một cách vô tội vạ.

Vậy là từ đó chúng ta tin tưởng được rằng chương trình của mình được thực thi, từng dòng một, từ trên xuống dưới, trừ khi có các câu lệnh IF..ELSE.. hay WHYLE. Đôi khi chúng ta cũng thấy sự hiện diện của câu lệnh GOTO ở đâu đó trong một vài ngôn ngữ như Java chẳng hạn, nhưng ở đó, chúng vẫn mang khả năng rất hạn chế, chỉ có thể dịch chuyển luồng điều khiển trong phạm vi một khối mã, chứ không phải phạm vi toàn chương trình.

Tóm lại, chúng ta có thể phát biểu rằng lập trình có cấu trúc áp đặt kỷ luật lên các phép dịch chuyển luồng điều khiển trực tiếp.

Object-Oriented Programming

Không dễ để tìm ra một định nghĩa rõ ràng về thế nào là lập trình hướng đối tượng. Một định nghĩa thường thấy là lập trình dựa trên khái niệm đối tượng – một sự kết hợp của dữ liệu và các hàm. Nhưng như thế là không đúng. Cơ bản thì định nghĩa này cho rằng o.f() là thứ gì đó khác với f(o). Đó là điều rất ngớ ngẩn, người ta đã chuyển các cấu trúc dữ liệu vào bên trong các hàm từ rất lâu trước khi OO được phát minh ra.

Một cách hiểu khác cũng hay được sử dụng, rằng OO là một cách để mô hình hóa thế giới thực bằng mã lệnh. Chưa kể đến việc thế nào là mô hình hóa thế giới thực là cũng không rõ. Câu trả lời này thực chất trả lời cho câu hỏi chúng ta OO bằng cách nào, đồng thời ngụ ý rằng OO giúp phần mềm trở nên dễ hiểu hơn bởi vì nó trở nên gần gũi với thế giới thực – thứ mà chúng ta quen thuộc, cả hai ý đó đều không có tác dụng định nghĩa OO là gì.

Một số định nghĩa thì đi ngược vào ba đặc tính kỳ diệu của OO, ngụ ý rằng OO có nghĩa là thỏa mãn ba điều này, hay một ngôn ngữ OO thì phải thỏa mãn ba khả năng: bao gói, kế thừa, đa hình.

Đến đây thì phải đi vào chi tiết hơn một chút.

Tính bao gói?

Bao gói có hai thể hiện: thứ nhất, nó cho phép kết hợp cấu trúc dữ liệu và hàm vào cùng một chỗ, thứ hai, nó cho phép ẩn giấu đi một số thành phần bên trong của một khối cấu trúc ra khỏi sự nhận biết của những tác nhân bên ngoài.

Về thể hiện thứ nhất, các lập trình viên, chẳng hạn của ngôn ngữ C, đã làm từ rất rất lâu trước khi khái niệm OO ra đời.

Với thể hiện thứ hai, điều tương tự cũng diễn ra. Mặc dù các ngôn ngữ càng hiện đại thì giúp cho lập trình viên sử dụng càng thuận tiện hơn, nhưng nếu xét riêng về khả năng bao gói, ngôn ngữ như C có thể làm tốt hơn C++, và C++ đến lượt nó lại làm tốt hơn những ngôn ngữ như Java.

Vậy nên không thể nói là tính bao gói có thể dính dáng gì đến việc định nghĩa nên OO được.

Tính kế thừa?

Tính kế thừa nói ngắn gọn là khai báo lại một nhóm các biến và hàm, và đây thật ra, lại một lần nữa, là điều mà các lập trình viên của ngôn ngữ C (cũng như rất nhiều ngôn ngữ cổ khác) đã làm từ rất lâu trước khi có những ngôn ngữ OO đầu tiên. Các ngôn ngữ sau này chỉ đơn giản là sử dụng cùng một cách tương tự để làm ra tính kế thừa cho mình mà thôi.

Mặc dù đúng là các ngôn ngữ sau này giúp lập trình viên triển khai tính kế thừa mà không còn phải dùng đến tip-trick nữa, thuận tiện hơn, an toàn hơn, đặc biệt là khi cần triển khai đa kế thừa. Nhưng như thế vẫn đưa đủ mạnh để biến tính kế thừa thành một đặc trưng, chứ đừng nói đến một định nghĩa, của các ngôn ngữ OO.

Tính đa hình?

Tính đa hình nghĩa là khả năng để tạo ra một chương trình với những con trỏ hàm. Các ngôn ngữ OO không tiên phong trong việc sử dụng các con trỏ hàm, nhưng chúng khiến việc sử dụng con trỏ hàm trở nên thuận tiện và an toàn hơn. Nếu không có các ràng buộc lên tính đa hình mà các ngôn ngữ OO mang lại, việc gán, truyền và sử dụng các con trỏ hàm sẽ trở nên vô cùng rắc rối và dễ gây lỗi. Vậy nên chúng ta có thể phát biểu rằng OO áp đặt kỷ luật lên các phép dịch chuyển luồng điều khiển gián tiếp.

Việc giải phóng khả năng triển khai tính đa hình biến các ngôn ngữ OO trở thành chén thánh cho các nhà thiết kế phần mềm – Dependency Inversion. Hãy quan sát sơ đồ kiến trúc sau, của một hệ thống thiếu vắng tính đa hình. Các mũi tên nét rời thể hiện luồng điều khiển, và mũi tên nét liền thể hiện hướng phụ thuộc trong mã nguồn.

Kiến trúc sư phần mềm có rất ít lựa chọn trong trường hợp này, cơ bản thì luồng điều khiển chỉ tới đâu thì hướng phụ thuộc mã nguồn theo tới đấy. Tầng Policy phụ thuộc vào tầng Database, điều đó có nghĩa là có thứ gì đó ở tầng Policy đã nhắc đến tên của thứ gì đó của tầng Database, tầng Policy bị ảnh hưởng bởi các thay đổi của tầng Database, nếu Database có thay đổi thì Policy sẽ phải biên dịch lại. Không kiến trúc sư phần mềm nào thích điều này. Hãy tưởng tượng người dùng phải cài đặt lại hệ điều hành để có thể sử dụng một thanh USB flashdisk mới mua về.

Nếu tính đa hình tham gia vào cuộc chơi, chúng ta sẽ có một sơ đồ kiến trúc trông rất khác, như sau:

Tầng Policy gọi tới Databsae thông qua một interface. Thực tế thì tại thời điểm thực thi interface này không tồn tại. Hãy để ý rằng hướng phụ thuộc mã nguồn từ Database tới Database Interface (mũi tên biểu diễn quan hệ kế thừa) đi ngược chiều hướng của luồng điều khiển (mũi tên nét rời). Chúng ta gọi điều này là đảo ngược phụ thuộc, và đó là thứ mà các kiến trúc sư phần mềm quan tâm sâu sắc.

Việc OO cung cấp một cách thức đơn giản và an toàn để triển khai đa hình đã giúp cho các kiến trúc sư khả năng đảo ngược bất kỳ mối phụ thuộc mã nguồn nào mà họ muốn. Nghe phức tạp nhưng điều đó đơn giản chỉ là đặt thêm một interface vào giữa mà thôi. Điều này có nghĩa là giờ đây họ có toàn quyền để điều khiển hướng phụ thuộc giữa hai khối kiến trúc bất kỳ trong hệ thống mà không cần quá quan tâm đến hướng của luồng điều khiển. Họ sẽ có thể bảo vệ bất kỳ module nào mà họ muốn – khỏi sự thay đổi đến từ module khác. Khả năng đó rất-mạnh. Đó là thứ mà chỉ OO mới có thể mang lại. Đó là thứ mà người ta thực sự tìm kiếm khi trả lời câu hỏi “OO là gì?” – ít nhất là dưới mắt của các nhà thiết kế phần mềm.

Nó mạnh như thế nào? Hãy tưởng tượng chúng ta sắp xếp để luồng điều khiển và các mối phụ thuộc trông như dưới đây, DatabaseUI trở thành những thứ phụ thuộc vào Policy, thay vì thuần túy theo một hướng.

Điều này có nghĩa là UIDatabase trở thành những module cắm và chạy của Policy. Những thay đổi của chúng không gây ra nhu cầu biên dịch lại Policy, ngược lại, Policy có thể được deploy một cách độc lập mà không cần đến sự hiện diện của UIDatabase , chúng ta gọi đó là chúng ta gọi đó là independent deployable. Nếu mà các module của một hệ thống có thể được deploy một cách độc lập như thế thì rõ là chúng cũng có thể được phát triển một cách độc lập với nhau, chúng ta gọi đó là independent developability.

Kết luận

OO không phải là khả năng tạo ra class, không phải là gom hàm và dữ liệu lại cùng một chỗ, không phải là nghĩ về chương trình như một thế giới của các đối tượng tương tác với nhau.

OO là khả năng thông qua đa hình để toàn quyền điều khiển được tất cả các mối phụ thuộc mã nguồn trong chương trình, cho phép kiến trúc sư tạo ra những kiến thúc “cắm và chạy”, tại đó những khối tổ chức nghiệp vụ cấp cao không phụ thuộc vào những khối tổ chức cấp thấp, và những khối cấp thấp thì có thể được phát triển một cách độc lập với những khối tổ chức cấp cao.

Functional Programming

Để dễ tưởng tượng thế nào là functional programming, chúng ta có thể nhìn vào chương trình in ra bình phương của 25 số nguyên đầu tiên, được viết bằng ngôn ngữ Lisp, lưu ý rằng trong Lisp, để gọi một hàm f với đối số là xy chúng ta sẽ sử dụng cú pháp (f x y):

(println (take 25 (map (fn [x] (* x x)) (range))))

Đừng hoảng loạn, ở đây chúng ta gọi hàm range, hàm này cho chúng ta một chuỗi vô tận các số nguyên, chúng ta tống chuỗi đó vào hàm map, hàm này trả cho chúng ta một chuỗi mới tương ứng với mỗi số nguyên, được chuyển đổi theo công thức từ hàm fn, hàm này nhận vào một phần tử và truyền phần tử đó vào thành hai đối số hàm nhân (dấu *). Chuỗi vô tận các kết quả từ hàm map trả về được hàm take bắt lấy và chọn ra 25 kết quả đầu tiên.

Lập trình hàm là như thế, toàn bộ chương trình là một loạt các lời gọi hàm. Chúng ta sẽ không đi sâu vào how to lập trình hàm, ở đây chúng ta nhìn vào một đặc trưng cối lõi của nó: không tồn tại “biến”. Nếu chúng ta lập trình chương trình trên bằng Java, chúng ta sẽ có một biến đếm tăng dần cho tới 25, lập trình hàm không dùng đến cơ chế thay đổi giá trị như thế. Trong những ngôn ngữ lập trình hàm, các biến được tạo ra với một giá trị, và chúng không biến động.

Điều đó rất quan trọng với các kiến trúc sư phần mềm. Thế giới lập trình mà có sự tồn tại của biến cũng sẽ có sự tồn tại của race condition, dealdlock, concurrent update problems… Tất cả các vấn đề đó xảy ra do khả năng thay đổi giá trị của biến. Nếu một biến không bao giờ bị thay đổi giá trị thì mọi vấn đề đó cũng tự nhiên biến mất. Là những người thiết kế hệ thống chúng ta dĩ nhiên cần quan tâm đến vấn đề của xử lý song song, chúng ta sẽ cần quan tâm đến việc cần áp dụng các biến bất biến vào những chỗ nào.

Một hệ thống phần mềm được thiết kế tốt sẽ tách biệt những cấu phần cần đến các biến với những cấu phần sử dụng thuần giá trị bất biến. Và các kiến trúc sư sẽ cố gắng đưa càng nhiều càng tốt các xử lý tính toán vào các cấu phần bất biến, trong khi đó tách đi nhiều hết mức có thể mã nguồn ra khỏi những cấu phần mà cho phép biến đổi giá trị.

Chúng ta cũng sẽ, trong chừng mực có thể, vận dụng ý tưởng về event sourcing. Nôm na có nghĩa là thay vì cố gắng biến đổi các giá trị, chúng ta lưu lại những phép tính được áp dụng lên giá trị ban đầu. Nếu giá trị tiếp tục có nhu cầu thay đổi, chúng ta đơn giản là bổ sung thêm công thức tính toán vào cơ sở dữ liệu. Đây chính là cách hoạt động của hầu hết các thệ thống VCS như Git, SVN… hay của các hệ thống blockchain. Nếu chúng ta có đủ bộ nhớ cũng như sức mạnh CPU để triển khai giải pháp này trên toàn bộ ứng dụng, chúng ta sẽ có một hệ thống thuần hàm. Với cách triển khai như thế, ứng dụng của chúng ta không còn là CRUD nữa mà chỉ có CR. Và theo đó, không tồn tại các vấn đề về tính toán đồng thời.

Tổng kết

Vậy là structured programming cố gắng áp đặt kỷ luật lên việc chuyển giao quyền điều khiển một cách trực tiếp. Object-Oriented Programming cố gắng áp đặt kỷ luật lên việc chuyển giao quyền điều khiển một cách gián tiếp. Và functional programming thì cố gắng áp đặt kỷ luật lên việc gán giá trị. Mỗi kỷ luật được thêm vào lại tước bớt đi khả năng của lập trình viên và tạo thêm các nguyên tắc cho thiết kế và kiến trúc của phần mềm. Mỗi phương pháp – hay nói đầy đủ – mỗi nề nếp dạng thức để suy nghĩ trong khuôn khổ công nghệ lập trình – lại giúp chúng ta ra quyết định trên ba mặt quan trọng của thiết kế:

  • Chức năng
  • Chia tách các cấu phần
  • Quản lý dữ liệu

Nếu chúng ta tiếp tục gặp vấn đề trên các phương diện khác của việc thiết kế, chúng ta sẽ tiếp tục đón chào sự ra đời của các khuôn khổ mới. Điều bất ngờ ở đây là ba khuôn khổ chúng ta vừa đề cập đã theo nhau ra đời trong thời gian vẻn vẹn mười năm của ngành lập trình điện toán, và chúng ta đã không đón nhận thêm bất kỳ khuôn khổ mới nào có tầm cỡ tương tự trong suốt 50 năm nay. Điều đó gợi ý cho các nhà phát triển phần mềm hiện tại về sự non trẻ của ngành thủ công phần mềm, cũng như tầm quan trọng kiên cố của các nguyên tắc lập trình căn bản.


Một số techtalk liên quan của Uncle Bob Robert C. Martin

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *