Strong typing vs. Strong testing
Software Engineering
36
White

Ngoc Dao viết ngày 23/03/2016

Tôi còn nhớ hồi làm việc về VBA ở Microsoft, chúng tôi đã có một cuộc tranh luận kéo dài về vấn đề kiểm tra kiểu tĩnh hay động. Trong "kiểm tra kiểu tĩnh" (static type checking), trình dịch (compiler) kiểm tra kiểu của biến trong lúc dịch. Ví dụ, nếu có function log() nhận tham số đầu vào là một số, và trong chương trình gọi hàm này như sau: log("foo"), tức là truyền vào một chuỗi. Trong trường hợp này, với kiểm tra kiểu tĩnh, trình dịch sẽ đưa ra một thông báo kiểu như: "Này chờ chút! Anh không thể truyền một chuỗi vào hàm này vì nó muốn một số cơ", và chương trình sẽ không được dịch. Còn với kiểm tra kiểu động, chương trình vẫn dịch tốt nhưng khi chạy sẽ báo lỗi runtime. Điểm không tốt của giải pháp này là các bug sẽ khó bị phát hiện cho tới khi ai đó chạy chương trình đến đoạn gọi hàm lỗi đó. Đặc biệt là khi hàm đó ít được gọi thì bug càng khó bị phát hiện. Trong thiết kế VBA, mục tiêu ban đầu là thiết kế ngôn ngữ kịch bản cho người dùng Excel, tôi ở phe định kiểu đơn giản ("weak typing"), bởi vì cách này dễ hiểu cho những người lập trình nghiệp dư, những người mà ngay cả khái niệm biến, kiểu cũng làm họ đau đầu. Cuối cùng, tôi đã thắng trong cuộc tranh cãi nội bộ đó, và kiểu "Variant" - kiểu có thể lưu trữ giữ liệu của bất cứ kiểu nào đã được đưa vào VBA và COM.

Tuy nhiên, tôi luôn tâm niệm trong đầu mình rằng định kiểu chặt chẽ (strong typing) là một cách tốt để compiler kiểm tra lỗi, và trong thực tế, với C++, tôi dùng hệ thống kiểu một cách rộng rãi để kiểm tra lỗi cho tất cả mọi thứ. Ví dụ, nếu muốn đảm bảo rằng nhân viên không bao giờ được nhận tiền thưởng, tôi tạo ra một hệ thống kiểu mới cho nhân viên và quản lí, sao cho chỉ quản lí mới có hàm PayBonus(). Thế là khi compile, chắc chắn là chỉ có quản lí mới có thể được thưởng, còn nhân viên thì không. Nhưng việc tạo ra kiểu mới thế này không hay lắm. Kiểm tra kiểu chỉ xem xem là "tôi có thể làm được điều này với object này không" chứ không chỉ ra rằng là nếu tôi đưa vào 1, 32, 'aardvark' thì đầu ra có là 2.12 không. Có một công cụ mạnh cho việc này: unit tests. Vì thế tôi đã rất thích ý tưởng kiểm tra chặt chẽ (strong testing) của Bruce Eckel, như một thay thể cho việc định kiểu chặt chẽ. Bây giờ, trước khi đến với Bruce, tôi muốn cảnh báo trước rằng việc định kiểu động (dynamic typing) có một mặt không tốt là: vì kiểu phải được kiểm tra lúc chạy (runtime) nên các ngôn ngữ định kiểu động thường chạy chậm hơn so với các ngôn ngữ định kiểu tĩnh. Điều này có thể không tốt, nhưng cũng có thể chẳng sao, tùy chương trinh. Cách định kiểu động của Python làm nó trở thành một ngôn ngữ siêu chậm. Tôi sử dụng một bộ lọc thư rác viết bằng Python. Thường mất vài giây để đánh dấu một thư rác. Như vậy là mất tầm một, hai phút để đánh dấu 10 hay 20 thư rác. Nếu bạn đang chạy một hệ thống web server sử dụng ngôn ngữ định kiểu động, thì điều này có vẻ đồng nghĩa với việc phải sử dụng gấp năm hay mười lần số server. Quá đắt đỏ. Vì thế, hãy tự cân nhắc yêu cầu của chương trình, nhưng nếu unit test cung cấp một công cụ kiểm tra code tốt, cũng đừng hoang tưởng về việc từ bỏ kiểm tra kiểu tĩnh.

Trong vài năm gần đây, tôi bắt đầu chú ý hơn vào năng suất của lập trình viên. Việc đào tạo lập trình viên thì tốn kém, trong khi CPU rẻ hơn nhiều, và tôi tin tưởng rằng chúng ta sẽ không còn phải chi trả nhiều cho phần cứng nữa, thay vào đó sẽ trả công lập trình viên một cách xứng đáng.

Vậy làm cách nào chúng ta có thể tạo động lực mạnh nhất lên vấn đề mà chúng ta cố gắng giải quyết? Mỗi khi một công cụ mới (đặc biệt là một ngôn ngữ lập trình) ra đời, nó sẽ đưa ra một số kiểu trừu tượng để giúp lập trình viên tránh phải đi sâu vào một số chi tiết. Tôi đã thấy rõ điều này, tuy nhiên, tôi luôn nhìn đó như là một món hời, mặc kệ người ta cố gắng thuyết phục tôi nên lờ những thứ vòng vo đó đi, thì tôi lại muốn lao vào để hiểu rõ các chi tiết trừu tượng đó. Perl là một ví dụ tuyệt vời cho điều này - sự gần gũi của ngôn ngữ này che giấu các chi tiết vô nghĩa trong quá trình xây dựng một chương trình máy tính, nhưng các cú pháp khó đọc lại là một cái giá không mong đợi.

Những năm trở lại đây đã làm rõ món hời (giao du với quỷ???) này trong các thuật ngữ của các ngôn ngữ truyền thống hơn và hướng đi của chúng là kiểm tra kiểu tĩnh. Điều này bắt đầu từ chuyện tình hai tháng của tôi với ngôn ngữ lập trình Perl, thứ đã đem lại năng suất cho tôi bằng cách ... (Chuyện tình kết thúc bởi vì cách đối xử sai lầm của Perl với việc tra cứu và classes; tới sau này tôi mới biết vấn đề thật sự đối với cú pháp). Kết quả của việc tranh luận giữa kiểm tra kiểu tĩnh và kiểm tra kiểu động không rõ ràng trong Perl, bởi vì bạn không thể xây dựng các dự án lớn để nhìn thấy kết quả này và cú pháp thì mù mịt trong các chương trình nhỏ hơn. Sau khi chuyển sang dùng Python (miễn phí ở trang http://www.python.org/)- một ngôn ngữ cho phép xây dựng các hệ thống lớn và phức tạp- tôi bắt đầu chú ý rằng bất chấp việc sự bất cẩn rành rành trong việc kiểm tra kiểu, chương trình Python dường như thực hiện rất tốt mà không cần nhiều lắm sự cố gắng, và cũng không có các loại rắc rối mà bạn nghĩ rằng mình sẽ gặp phải với một ngôn ngữ không có kiểm tra kiểu tĩnh, mà chúng ta đều tưởng rằng đó là cách duy nhất để giải quyết đúng một vấn đề lập trình. Đây là một bài toán: nếu kiểm tra kiểu tĩnh quan trọng như vậy, thì tại sao người ta có thể xây dựng các chương trình Python lớn, phức tạp (tốn ít thời gian và công sức hơn các ngôn ngữ đối lập), mà không hặp phải tai họa tôi đã chắc chắn rằng sẽ xảy ra?

Điều này vứt bỏ sự chấp nhận mù quáng của tôi với việc kiểm tra kiểu tĩnh (có được khi chuyển từ pre-ANSI C tới C++, một sự chuyển biến rất ấn tượng) đến nỗi mà lần tiếp theo khi tôi tính toán kết quả của việc kiểm tra ngoại lệ trong Java, tôi đã hỏi "tại sao?" và dẫn đến một cuộc tranh luận mà tôi được chỉ cho rằng nếu tôi cứ khăng kh xA3 chỉ ra rằng công dụng của RuntimeException như là một lớp để tránh việc phải kiểm tra tất cả các ngoại lệ khác. Bây giờ tôi vẫn đúng khi thực hiện điều đó (Cần chú ý rằng Martin Fowler cũng có một ý tưởng tương tự tại thời điểm đó), nhưng thỉnh thoảng tôi vẫn nhận được các email cảnh báo rằng tôi đã vi phạm tất cả và rằng đó là hành động đúng đắn và có lẽ những người yêu nước sẽ cần phải xử sự như vậy (chào, các bác tới từ FBI! Mừng các bác vào weblog của tôi!). Nhưng việc quyết định rằng các ngoại lệ được kiểm tra dường như gặp nhiều vấn đề hơn cái giá của nó (the checking, not the exception—I believe that a single, consistent error reporting mechanism is essential) không trả lời được câu hỏi "Tại sao Python làm việc tốt như vậy, măc dù những người khôn ngoan cho rằng nó sẽ mang lại một đống các điều tồi tệ?". Python và các ngôn ngữ kiểu động tương tự rất lười kiểm tra kiểu dữ liệu. Thay vì định kiểu chặt chẽ các đối tượng, sớm nhất có thể (như Java), những ngôn ngữ như Ruby, Smalltalk, và Python, cơ chế ép kiểu được đặt ra một cách lỏng lẻo nhất, và chỉ tính toán kiểu những khi cần thiết.

Điều này đưa ra ý tưởng định kiểu ngầm hoặc định kiểu theo cấu trúc, đôi khi được gọi là "định kiểu con vịt" (như người ta nói: thứ gì đi như vịt, kêu cạp cạp, thì chúng ta có thể đối xử với nó như một con vịt"). Điều này có nghĩa là bạn có thể gửi bất cứ thông điệp nào tới bất kì đối tượng nào, và ngôn ngữ chỉ quan tâm tới việc đối tượng đó có thể chấp nhận thông điệp đó hay không. Nó không yêu cầu đối tượng đó phải là một kiểu riêng biệt. Ví dụ, bạn có các con thú cưng có thể nói chuyện trong Java, bạn có thể viết như sau:

// Speaking pets in Java:
interface Pet {
void speak();
}

class Cat implements Pet {
public void speak() { System.out.println("meow!"); }
}
class Dog implements Pet {
public void speak() { System.out.println("woof!"); }
}
public class PetSpeak {
static void command(Pet p) { p.speak(); }
public static void main(String[] args) {
Pet[] pets = { new Cat(), new Dog() };
for(int i = 0; i < pets.length; i++)
command(pets[i]);
}
}

Chú ý rằng command() phải biết chính xác kiểu của tham số mà nó chấp nhận- lớp Pet- và nó sẽ không chấp nhận các kiểu khác. Theo đó tôi phải cấp thứ bậc cho Pet, và thừa kế Dog và Cat để có thể sử dụng nó với phương thức command(). Trong một thời gian dài, tôi cứ nghĩ rằng upcast là một phần vốn có của việc lập trình hướng đối tượng, và tìm thấy các câu hỏi về sự ngờ nghệch của lập trình viên ngôn ngữ Smalltalk (smalltalker) và những người tương tự rất khó chịu. Nhưng khi tôi bắt đầu làm việc với Python, tôi khám phá ra điều lý thú sau. Đoạn mã trên có thể được dịch trực tiếp sang Python như sau:

# Speaking pets in Python:
class Pet:
def speak(self): pass
class Cat(Pet):
def speak(self):
print "meow!"
class Dog(Pet):
def speak(self):
print "woof!"
def command(pet):
pet.speak()
pets = [Cat(), Dog()]
for pet in pets:
command(pet)

Nếu bạn chưa hề biết Python trước đây, bạn sẽ chú ý rằng nó định nghĩa lại khái niệm ngôn ngữ súc tích, nhưng theo một cách tốt hơn. Bạn có nghĩ C/C++ là ngắn gọn không? Hãy vứt bỏ các dấu ngoặc móc đó đi- cách thụt đầu dòng cũng đã có tác dụng với người ta rồi, do đó chúng ta sẽ sử dụng điều này vào việc biểu thị phạm vi (scope). Tham số có kiểu và trả về dữ liệu được định kiểu? Hãy để ngôn ngữ tự phân loại điều đó! Trong quá trình tạo lớp, lớp tổ tiên được biểu thị ở trong dấu ngoặc đơn, def có nghĩa là tạo một hàm hoặc một phương thức. Mặt khác, Python rõ ràng về điều này (self được theo quy ước). Từ khóa pass có nghĩa là "tôi sẽ định nghĩa sau", cũng giống như từ khóa abstract vậy. Chú ý rằng command(pet) chỉ cho thấy rằng nó cần một đối tượng gọi là pet, nhưng không đưa ra bất cứ thông tin nào về kiểu ràng buộc đối tượng đó. Điều này là vì nó không quan tâm, chỉ cần bạn có thể gọi speak(), hoặc bất cứ thứ gì mà hàm hoặc phương thức của bạn muốn gọi. Đây gọi là kiểu mù mờ/ kiểu con vịt, chúng ta sẽ xem xét kỹ hơn trong một lúc. Cũng vậy, command(pet) chỉ là một hàm bình thường, chấp nhận được trong Python. Điều này có nghĩa là, Python không rõ ràng khiến bạn phải xem bất cứ thứ gì cũng là đối tượng, bơi vì đôi khi bạn chỉ cần một hàm đơn giản. Trong Python, cả danh sách và từ điển (list and dictionary) đều quan trọng nên được đưa vào phần nhân của ngôn ngữ, vì thế tôi không cần phải import bất cứ thư viện nào khác để sử dụng. Bạn có thể thấy ở dây:

pets = [Cat(), Dog()]

Một danh sách được tạo ra với hai đối tượng mới thuộc kiểu Cat và Dog. Bộ khởi tạo được gọi, nhưng không cần từ khóa "new" nào (và bây giờ bạn hãy xem lại Java và nhận ra rằng cũng không cần từ khóa new nào ở đây- đây là một sự rườm rà được thừa kế từ C++). Duyệt qua một dãy cũng rất quan trọng đến nỗi nó trở thành một toán tử cơ bản trong Python:

for pet in pets:

Chọn mỗi đối tượng trong danh sách và gán vào biến pet. Rõ ràng và đơn giản hơn nhiều so với những gì của Java. tôi nghĩ vậy, ngay cả khi so sánh với cú pháp "foreach" trong J2SE5. Phần xuất liệu cũng giống như phiên bản Java, và bạn có thể thấy vì sao Python thường được gọi là "giả lệnh khả được" (executable pseudocode). Không chỉ vì nó đơn giản đến mức có thể dùng như giả ngữ, nó còn có khả năng được thi hành thật sự. Điều này có nghĩa là bạn có thể dễ dàng diễn tả ý tưởng trong Python, và sau khi nó hoạt động, bạn có thể viết lại trong Java/C++/C# hoặc bất cứ ngôn ngữ nào bạn chọn. Hoặc đôi khi bạn nhận ra rằng vấn đề đã được giải quyết trong Python, tại sao phải viết lại cho phiền toái? (đây thường là điều tôi nghĩ) Tôi đã ra bài tập có gợi ý bằng Python, trong suốt chuyên đề, không có khi nào tôi được đưa cho một bức tranh hoàn chỉnh, nhưng mọi người có thể thấy được tôi cần gì ở lời giải, và họ có thể làm việc. Và tôi có thể chắc chắn rằng giả lệnh là đúng bằng cách thực hiện chúng.

Nhưng phần thú vị là ở đây: bởi vì phương thức command(pet) không quan tâm tới kiểu dữ liệu của tham số, do đó tôi không cần thừa kế. Tôi có thể viết lại chương trình Python mà không cần sử dụng lớp căn bản như sau:
# Speaking pets in Python, but without base classes:
class Cat:
def speak(self):
print "meow!"
class Dog:
def speak(self):
print "woof!"
class Bob:
def bow(self):
print "thank you, thank you!"
def speak(self):
print "hello, welcome to the neighborhood!"
def drive(self):
print "beep, beep!"
def command(pet):
pet.speak()
pets = [Cat(), Dog(), Bob()]
for pet in pets:
command(pet)

Vì command(pet) chỉ quan tâm rằng nó có thể gửi thông điệp speak() tới tham số của nó, tôi đã bỏ lớp Pet đi, và thêm cả một lớp non-pet tên là Bob vào, lớp này cũng có phương thức speak(), do đó nó cũng làm việc được với hàm command(pet). Tại điểm này, một ngôn ngữ kiểu tĩnh sẽ không thể nào chấp nhận, khăng khăng rằng sự ủy mị này sẽ dẫn đến thảm họa và tàn tật. Rõ ràng, đôi khi sử dụng kiểu "sai" với command() sẽ gây thoát chương trình. Lợi ích của việc sử dụng cú pháp đơn giản và rõ ràng không gây nguy hiểm- mà thậm chí còn tăng năng suất lên 5 đến 10 lần khi làm việc với Java hoặc C++. Điều gì xảy ra khi một rắc rối xuất hiện trong chương trình Python- một đối tượng đôi khi bị đặt ở sai chổ? Python thông báo tất cả các lỗi dưới dạng ngoại lệ (exceptions), như Java hoặc C# làm và như C++ cần phải làm. Vì vậy bạn có thể nhận ra có một rắc rối ở đây, nhưng nó dễ dàng được nhìn thấy khi thực thi. Bạn ồ lên "A- ha! đây là vấn đề của bạn: bạn không thể bảo đảm sự đúng đắn của chương trình của bạn bởi vì bạn không có đủ thời gian cần thiết để kiểm tra lỗi lúc thực thi". Khi tôi viết cuốn "Thinking in C++". xuất bản lần đầu tiên (Prentice Hall, 1998), tôi đã kết hợp chặt chẽ một cách kiểm duyệt rất thô thiển: tôi viến một chương trình tự động tìm kiếm tất cả các đoạn mã trong một cuốn sách (sử dụng chú thích để đánh dâu nơi bắt đầu và kết thúc đoạn mã của từng mục), rồi sau đó xây dựng makefiles để có thể biên dịch đoạn mã. Bằng cách này tôi có thể khẳng định rằng tất cả các đoạn mã trong cuốn sách của tôi có thể biên dịch, lấy lí do đó, để tôi có thể nói rằng "tất cả các đoạn mã trong sách đều đúng". tôi lờ đi các tiếng rầy la rằng "Dịch được không có nghĩa là chạy tốt", bởi vì đó là một bước để tự động kiểm tra đoạn mã đầu tiên (như mọi người đọc sách lập trình đều biết, nhiều tác giả vẫn chưa thực sự cố gắng trong việc kiểm tra tính đúng đắn của các đoạn mã). Nhưng theo một các tự nhiên, một số ví dụ vẫn không chạy chính xác, và khi đã nhạn đủ thông báo về các ví dụ này sau nhiều năm tôi bắt đầu nhận ra rằng tôi không thể làm ngơ đi việc kiểm tra chương trình. Tôi bắt đầu cảm thấy mạnh mẽ điều này trong lần tái bản thứ ba của cuốn "Thinking in Java", tôi đã viết:

Nếu không được kiểm tra, nó sẽ đổ vỡ.

Điều này có nghĩa là, nếu một chương trình được biên dịch trong một ngôn ngữ kiểu tĩnh, nó chỉ có nghĩa rằng nó đã trải qua một số bước kiểm tra. Điều này có nghĩa rằng cú pháp của nó đã được khẳng định là đúng (Python cũng kiểm tra cú pháp khi biên dịch- nó chỉ không có nhiều ràng buộc cú pháp quá thôi). Nhưng không chắc chắn gì chương trình của bạn sẽ đúng đắn chỉ vì bộ biên dịch chấp nhận đoạn mã. Nếu như chương trình của bạn có thể chạy, thì cũng không chắc chắn nó chạy đúng. Bạn chỉ có thể chắc chắn được điều này, bất chấp ngôn ngữ của bạn là kiểu tĩnh hay kiểu động, khi nó đáp ứng được yêu cầu đặt ra của chương trình mà thôi. Và bạn phải tự mình viết một vài test. Điều này, tất nhiên, là các unit test, acceptance test, và nhận ra rằng bộ biên dịch chỉ là một bộ kiểm tra (không đầy đủ) mà thôi, do đó việc hiểu rằng một ngôn ngữ kiểu động có thể sẽ năng suất hơn nhiều nhưng cũng đảm bảo như các chương trình được viết trong các ngôn ngữ định kiểu tĩnh. Tất nhiên, Martin cũng thường nhận được câu hỏi trong chú thích rằng "Làm thể nào mà bạn có thể nghĩ vậy?". Rất nhiều câu hỏi khiến tôi bắt đầu đấu tranh giữa ngôn ngữ kiểu tĩnh và ngôn ngữ kểu động. Và tất nhiên, cả hai chúng tôi bắt đầu từ những kẻ biện hộ cho ngôn ngữ định kiểu tĩnh. Điều lí thú là nó dẫn đến một trải nghiệm rằng- như một kẻ bắt đầu bị nhiễm thói quen kiểm thử hoặc bắt đầu học một kiểu ngôn ngữ mới- để đánh giá lại một tín ngưỡng mà mình đã có từ lâu.

Nguồn: The best software writing I
Gốc: Strong Typing vs. Strong Testing

Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

White

Ngoc Dao

102 bài viết.
252 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
Cùng một tác giả
White
56 6
Làm thế nào để nâng cấp trang web mà không làm gián đoạn dịch vụ? Đây là câu hỏi phỏng vấn các công ty lớn thường hỏi khi bạn xin vào vị trí làm lậ...
Ngoc Dao viết 2 năm trước
56 6
White
32 0
Bài viết này giải thích sự khác khác nhau giữa hai ngành khoa học máy tính (computer science) và kĩ thuật phần mềm (software engineering), hi vọng ...
Ngoc Dao viết gần 2 năm trước
32 0
White
28 1
Nếu là team leader, giám đốc công ty hay tướng chỉ huy quân đội, vấn đề cơ bản bạn gặp phải là “hướng mọi người đi theo con đường bạn chỉ ra”. Thử...
Ngoc Dao viết gần 2 năm trước
28 1
Bài viết liên quan
White
1 1
Lập trình đôi (pair programming) là hình thức lập trình trong đó 2 người cùng hợp tác làm việc trên cùng màn hình (có thể khác bàn phím v.v.). Bài ...
Ngoc Dao viết gần 2 năm trước
1 1
White
5 1
Trong quyển sách Beyond Java, xuất bản vài năm trước có đoạn:Java has characteristics that many of us take for granted. You can find good Java deve...
Ngoc Dao viết gần 2 năm trước
5 1
White
3 0
Lập trình viên quá cố người Mỹ Phil Karlton có câu nổi tiếng: There are only two hard things in Computer Science: cache invalidation and naming th...
Ngoc Dao viết gần 2 năm trước
3 0
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


White
{{userFollowed ? 'Following' : 'Follow'}}
102 bài viết.
252 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!