Quay lại
03/05/2026 56 phút

Chương 27: Coding

Mọi máy tính đều thực thi mã máy, nhưng lập trình bằng mã máy thì chẳng khác nào ăn cơm bằng tăm. Từng miếng quá nhỏ và quá trình thì nhọc nhằn đến mức bữa tối tưởng dài như vô tận. Tương tự, byte của mã máy thực hiện những tác vụ tính toán nhỏ bé và đơn giản nhất mà bạn có thể tưởng tượng—nạp một số từ bộ nhớ vào bộ vi xử lý, cộng nó với một số khác, lưu kết quả trở lại bộ nhớ—đến mức thật khó hình dung chúng có thể gộp lại thành một bữa ăn trọn vẹn như thế nào.

Chí ít thì chúng ta cũng đã tiến bộ hơn thời kỳ sơ khai ở đầu chương trước, khi phải dùng các công tắc trên bảng điều khiển để nhập dữ liệu nhị phân vào bộ nhớ. Ở chương đó, ta phát hiện ra rằng mình có thể viết những chương trình đơn giản cho phép nhập từ bàn phím và màn hình và xem các byte mã máy hệ thập lục phân. Cách này chắc chắn là tốt hơn, nhưng nó chưa phải là sự cải tiến cuối cùng.

Như bạn đã biết, các byte của mã máy gắn liền với các từ gợi nhớ (mnemonics) ngắn gọn, như MOV, ADD, JMP, và HLT, cho phép ta nhắc đến mã máy bằng một thứ ngôn ngữ na ná tiếng Anh. Các từ gợi nhớ này thường được viết kèm với các toán hạng (operands) để chỉ rõ hơn mã máy sẽ làm gì. Ví dụ, byte mã máy 46h của 8080 ra lệnh cho vi xử lý chuyển byte lưu tại địa chỉ bộ nhớ đang được tham chiếu bởi giá trị 16-bit trong cặp thanh ghi HL vào thanh ghi B. Lệnh này được viết súc tích hơn là:

MOV B,M

trong đó chữ M viết tắt cho "memory" (bộ nhớ). Toàn bộ tập hợp các từ gợi nhớ này (kèm theo một vài tính năng bổ sung) tạo thành một ngôn ngữ lập trình được gọi là hợp ngữ (assembly language). Viết chương trình bằng hợp ngữ dễ dàng hơn nhiều. Vấn đề duy nhất là CPU không thể trực tiếp hiểu hợp ngữ!

Trong những ngày đầu làm việc với máy tính thô sơ như vậy, có lẽ bạn sẽ dành rất nhiều thời gian để viết các chương trình hợp ngữ ra giấy. Chỉ khi nào hài lòng, bạn mới bắt đầu dịch tay (hand-assemble) nó, nghĩa là bạn sẽ chuyển đổi các câu lệnh hợp ngữ thành các byte mã máy bằng tay dựa vào một bảng tra cứu hoặc tài liệu tham khảo, rồi sau đó nhập chúng vào bộ nhớ.

Điều làm cho việc dịch tay cực nhọc chính là các lệnh nhảy (jump) và gọi (call). Để dịch tay một lệnh JMP hoặc CALL, bạn phải biết chính xác địa chỉ nhị phân của đích, và điều đó phụ thuộc vào việc tất cả các lệnh mã máy khác đã nằm đúng vị trí. Sẽ tốt hơn nhiều nếu để máy tính làm công việc chuyển đổi này cho bạn. Nhưng bằng cách nào?

Đầu tiên bạn có thể viết một trình soạn thảo văn bản (text editor), tức là một chương trình cho phép bạn gõ các dòng văn bản và lưu chúng thành một tệp. (Thật không may, bạn sẽ phải dịch tay chính cái chương trình này). Sau đó, bạn có thể tạo ra các tệp văn bản chứa các lệnh hợp ngữ. Bạn cũng sẽ cần dịch tay một chương trình khác, gọi là trình biên dịch hợp ngữ (assembler). Chương trình này sẽ đọc tệp văn bản chứa các lệnh hợp ngữ và chuyển đổi chúng thành mã máy, sau đó lưu vào một tệp khác. Nội dung của tệp đó sau này có thể được tải vào bộ nhớ để thực thi.

Nếu bạn đang chạy hệ điều hành CP/M trên máy tính 8080, phần lớn công việc này đã được làm sẵn cho bạn. Bạn đã có mọi công cụ cần thiết. Trình soạn thảo văn bản có tên là ED.COM cho phép bạn tạo và chỉnh sửa tệp văn bản. (Các trình soạn thảo văn bản đơn giản ngày nay bao gồm Notepad trên Windows và TextEdit trên macOS). Giả sử bạn tạo một tệp văn bản có tên PROGRAM1.ASM. Đuôi tệp ASM chỉ ra rằng tệp này chứa một chương trình hợp ngữ. Tệp đó trông đại loại như này:

        ORG 0100h
        LXI DE,Text
        MVI C,9
        CALL 5
        RET
Text:   DB 'Hello!$'
        END

Tệp này có một vài câu lệnh mà ta chưa thấy. Lệnh đầu tiên là ORG (Origin - Gốc). Câu lệnh này không tương ứng với một lệnh 8080 nào cả. Thay vào đó, nó chỉ thị rằng địa chỉ của câu lệnh tiếp theo sẽ bắt đầu tại 0100h, đây là địa chỉ mà CP/M thường tải các chương trình vào bộ nhớ.

Câu lệnh tiếp theo là LXI (Load Extended Immediate), dùng để nạp một giá trị 16-bit vào cặp thanh ghi DE. Đây là một trong số vài lệnh của Intel 8080 mà CPU của tôi không triển khai. Trong trường hợp này, giá trị 16-bit đó được cung cấp dưới dạng nhãn Text. Nhãn đó nằm ở gần cuối chương trình, ngay trước câu lệnh DB (Data Byte), mà ta cũng chưa từng thấy. Lệnh DB có thể được theo sau bởi nhiều byte phân tách bằng dấu phẩy hoặc (như tôi làm ở đây) bởi một đoạn văn bản nằm trong dấu nháy đơn.

Lệnh MVI (Move Immediate) đưa giá trị 9 vào thanh ghi C. Lệnh CALL 5 gọi tới hệ điều hành CP/M, hệ điều hành này sẽ xem xét giá trị trong thanh ghi C và nhảy đến hàm thích hợp. Hàm đó sẽ hiển thị một chuỗi ký tự bắt đầu tại địa chỉ do cặp thanh ghi DE cung cấp và dừng lại khi gặp ký tự đô-la. (Bạn sẽ nhận thấy văn bản ở dòng cuối của chương trình kết thúc bằng một dấu đô-la. Việc dùng dấu đô-la để báo hiệu kết thúc một chuỗi ký tự khá là kỳ quặc, nhưng đó lại là cách CP/M hoạt động). Lệnh RET cuối cùng kết thúc chương trình và trả quyền điều khiển về cho CP/M. Lệnh END báo hiệu sự kết thúc của tệp hợp ngữ.

Giờ thì bạn đã có một tệp văn bản chứa 7 dòng. Bước tiếp theo là biên dịch nó. CP/M bao gồm một chương trình tên là ASM.COM, đó chính là trình biên dịch hợp ngữ của CP/M. Bạn chạy ASM.COM từ dòng lệnh của CP/M như sau:

ASM PROGRAM1.ASM

Chương trình ASM sẽ kiểm tra tệp PROGRAM1.ASM và tạo ra một tệp mới, có tên là PROGRAM1.COM, chứa mã máy tương ứng với các câu lệnh hợp ngữ mà chúng ta đã viết. (Thực ra còn một bước nữa trong quá trình này, nhưng nó không quan trọng trong bối cảnh những gì đang diễn ra ở đây).

Tệp PROGRAM1.COM chứa 16 byte sau:

11 09 01 0E 09 CD 05 00 C9 48 65 6C 6C 6F 21 24

3 byte đầu tiên là lệnh LXI, 2 byte tiếp theo là lệnh MVI, 3 byte tiếp nữa là lệnh CALL, và byte tiếp theo là lệnh RET. 7 byte cuối cùng là mã ASCII cho năm chữ cái của từ "Hello", dấu chấm than và dấu đô-la. Sau đó, bạn có thể chạy chương trình PROGRAM1 từ dòng lệnh CP/M:

PROGRAM1

Hệ điều hành sẽ tải chương trình đó vào bộ nhớ và chạy. Xuất hiện trên màn hình sẽ là lời chào:

Hello!

Một assembler (trình biên dịch hợp ngữ) như ASM.COM đọc một chương trình hợp ngữ (thường gọi là tệp mã nguồn - source code) và ghi ra một tệp chứa mã máy—một tệp thực thi (executable). Nhìn chung, các assembler là những chương trình khá đơn giản vì có sự tương ứng một-một giữa các câu lệnh hợp ngữ và mã máy. Assembler hoạt động bằng cách tách mỗi dòng văn bản thành các từ gợi nhớ và các đối số, sau đó so sánh những từ và chữ cái nhỏ này với một danh sách mà nó duy trì chứa tất cả các từ gợi nhớ và đối số có thể có. Quá trình này gọi là phân tích cú pháp (parsing), và nó đòi hỏi rất nhiều lệnh CMP theo sau bởi các lệnh nhảy có điều kiện. Những phép so sánh này sẽ tiết lộ lệnh mã máy nào tương ứng với câu lệnh nào.

Chuỗi byte chứa trong tệp PROGRAM1.COM bắt đầu bằng 11h, tức là lệnh LXI. Theo sau là các byte 09h01h, tạo thành địa chỉ 16-bit 0109h. Assembler đã tính toán địa chỉ này cho bạn: Nếu bản thân lệnh LXI nằm ở 0100h (như khi CP/M tải chương trình vào bộ nhớ để chạy), thì địa chỉ 0109h chính là nơi chuỗi văn bản bắt đầu. Nhìn chung, một lập trình viên sử dụng assembler không cần phải lo lắng về các địa chỉ cụ thể gắn với các phần khác nhau của chương trình.

Tất nhiên, người đầu tiên viết ra trình assembler đầu tiên đã phải tự tay biên dịch (dịch tay) chương trình đó. Một người muốn viết một assembler mới (có thể được cải tiến) cho cùng một máy tính có thể viết nó bằng hợp ngữ và sau đó dùng assembler đầu tiên để biên dịch nó. Khi cái assembler mới đã được biên dịch xong, nó có thể tự biên dịch chính nó.

Mỗi khi một vi xử lý mới được phát triển đều cần một assembler mới. Tuy nhiên, assembler mới có thể được viết lần đầu trên một máy tính hiện có bằng cách sử dụng assembler của máy tính đó. Cái này được gọi là một trình biên dịch chéo (cross-assembler). Nó chạy trên Máy tính A nhưng tạo ra mã để chạy trên Máy tính B.

Assembler loại bỏ đi những khía cạnh ít tính sáng tạo nhất của lập trình hợp ngữ (phần dịch tay), nhưng hợp ngữ vẫn còn hai vấn đề lớn. Có lẽ bạn cảm thấy vấn đề đầu tiên là lập trình bằng hợp ngữ rất tẻ nhạt. Bạn đang làm việc ở tầng thấp nhất của CPU, và bạn phải bận tâm đến từng tiểu tiết.

Vấn đề thứ hai là hợp ngữ không có tính di động (portable). Nếu bạn viết một chương trình hợp ngữ cho Intel 8080, nó sẽ không chạy được trên Motorola 6800. Bạn phải viết lại chương trình đó bằng hợp ngữ của 6800. Dù việc này có thể không khó khăn bằng lúc viết chương trình ban đầu vì bạn đã giải quyết được các vấn đề tổ chức và thuật toán chính, nhưng đó vẫn là một khối lượng công việc khổng lồ.

Phần lớn công việc của máy tính là tính toán toán học, nhưng cách mà toán học được thực hiện trong hợp ngữ lại cồng kềnh và vụng về. Sẽ tốt hơn rất nhiều nếu ta có thể diễn đạt các phép toán bằng cách sử dụng các ký hiệu đại số đã được thời gian kiểm chứng, ví dụ như:

Angle = 27.5
Hypotenuse = 125.2
Height = Hypotenuse * Sine(Angle)

Nếu văn bản này thực sự là một phần của chương trình máy tính, mỗi dòng trong ba dòng này sẽ được gọi là một câu lệnh (statement). Trong lập trình, cũng như trong đại số, các tên gọi như Angle, Hypotenuse, và Height được gọi là biến (variable) vì có thể gán các giá trị khác nhau vào nó. Dấu bằng thể hiện phép gán: Biến Angle được gán giá trị 27.5, và Hypotenuse được gán 125.2. Sine là một hàm. Ở đâu đó có một đoạn mã sẽ tính toán giá trị sin lượng giác của một góc và trả về giá trị đó.

Cũng cần lưu ý rằng những con số này không phải là số nguyên thường thấy trong hợp ngữ; đây là những con số có dấu thập phân và phần lẻ. Trong thuật ngữ máy tính, chúng được gọi là số thực dấu phẩy động (floating-point numbers).

Nếu những câu lệnh như vậy nằm trong một tệp văn bản, lẽ ra chúng ta phải có khả năng viết một chương trình hợp ngữ đọc tệp văn bản đó và chuyển đổi các biểu thức đại số này thành mã máy để thực hiện tính toán. Vâng, tại sao lại không chứ?

Thứ mà bạn đang mon men tạo ra ở đây được gọi là một ngôn ngữ lập trình bậc cao (high-level programming language). Hợp ngữ được coi là một ngôn ngữ bậc thấp (low-level) vì nó nằm rất sát với phần cứng máy tính. Mặc dù thuật ngữ "bậc cao" được dùng để chỉ bất kỳ ngôn ngữ lập trình nào khác ngoài hợp ngữ, nhưng một số ngôn ngữ lại có bậc cao hơn các ngôn ngữ khác. Nếu bạn là giám đốc một công ty, bạn có thể ngồi vào máy tính và gõ (hoặc tốt hơn hết là cứ gác chân lên bàn và đọc chính tả): "Tính toán toàn bộ lỗ lãi năm nay, viết báo cáo thường niên, in ra vài nghìn bản và gửi cho tất cả cổ đông", lúc đó bạn mới thực sự đang làm việc với một ngôn ngữ ở mức siêu cao! Trong thế giới thực, các ngôn ngữ lập trình còn xa mới đạt đến lý tưởng đó.

Ngôn ngữ con người là kết quả của hàng ngàn năm chịu ảnh hưởng phức tạp, những thay đổi ngẫu nhiên và sự thích nghi. Ngay cả các ngôn ngữ nhân tạo như Esperanto cũng bộc lộ cội rễ của chúng từ các ngôn ngữ thực. Tuy nhiên, các ngôn ngữ máy tính bậc cao lại là những ý tưởng được thiết kế có chủ đích. Tạo ra ngôn ngữ lập trình là thử thách khá hấp dẫn với một số người vì ngôn ngữ đó định hình cách con người truyền đạt mệnh lệnh tới máy tính. Khi tôi viết ấn bản đầu tiên của cuốn sách này, tôi tìm thấy một ước tính vào năm 1993 rằng đã có hơn 1000 ngôn ngữ bậc cao được phát minh và thực thi kể từ đầu thập niên 1950. Đến cuối năm 2021, một trang web có tên Online Historical Encyclopedia of Programming Languages (hopl.info) đã đưa ra tổng số là 8.945.

Tất nhiên, chỉ đơn thuần định nghĩa một ngôn ngữ bậc cao (bao gồm việc phát triển cú pháp (syntax) để diễn đạt mọi thứ bạn muốn làm với ngôn ngữ đó) là chưa đủ. Bạn còn phải viết một trình biên dịch (compiler), tức là chương trình chuyển đổi các câu lệnh của ngôn ngữ bậc cao thành mã máy. Tương tự như assembler, một trình biên dịch phải đọc tệp mã nguồn từng ký tự một và bóc tách nó thành các từ ngắn, ký hiệu, và số. Tuy nhiên, trình biên dịch lại phức tạp hơn assembler rất nhiều. Assembler được đơn giản hóa phần nào nhờ sự tương ứng một-một giữa lệnh hợp ngữ và mã máy. Còn một trình biên dịch thường phải dịch một câu lệnh duy nhất của ngôn ngữ bậc cao thành rất nhiều lệnh mã máy. Việc viết các trình biên dịch không hề dễ dàng. Có cả những cuốn sách dày cộp chuyên bàn về thiết kế và cấu trúc của chúng.

Ngôn ngữ bậc cao có cả ưu và nhược điểm. Ưu điểm hàng đầu là ngôn ngữ bậc cao thường dễ học và dễ lập trình hơn hợp ngữ. Các chương trình viết bằng ngôn ngữ bậc cao thường rõ ràng và súc tích hơn. Ngôn ngữ bậc cao cũng có tính di động (portable)—nghĩa là chúng không bị trói buộc vào một bộ xử lý cụ thể nào như hợp ngữ. Chúng cho phép lập trình viên làm việc mà không cần biết về cấu trúc sâu xa của máy mà chương trình sẽ chạy. Tất nhiên, nếu bạn cần chạy chương trình trên nhiều bộ xử lý khác nhau, bạn sẽ cần các trình biên dịch khác nhau để tạo ra mã máy tương ứng cho từng bộ xử lý đó. Bản thân các tệp thực thi cuối cùng vẫn luôn dành riêng cho các CPU cá biệt.

Mặt khác, một lập trình viên hợp ngữ giỏi thường có thể viết ra các đoạn mã chạy nhanh và hiệu quả hơn những gì trình biên dịch có thể tạo ra. Điều này có nghĩa là một tệp thực thi được tạo ra từ ngôn ngữ bậc cao sẽ lớn hơn và chậm hơn so với một chương trình có chức năng tương đương nhưng được viết bằng hợp ngữ. (Tuy nhiên, trong những năm gần đây, điều này đã trở nên bớt rõ ràng hơn khi vi xử lý ngày càng phức tạp và trình biên dịch cũng trở nên tinh vi hơn trong việc tối ưu hóa mã).

Mặc dù một ngôn ngữ bậc cao nói chung giúp bộ xử lý dễ dùng hơn nhiều, nhưng không hề làm nó mạnh hơn. Một số ngôn ngữ bậc cao không hỗ trợ các thao tác vốn rất phổ biến trên CPU, chẳng hạn như dịch bit (bit shifting) và kiểm tra bit (bit testing). Những tác vụ này có thể sẽ khó nhằn hơn khi sử dụng ngôn ngữ bậc cao.

Trong thời kỳ đầu của máy tính gia đình, hầu hết các chương trình ứng dụng được viết bằng hợp ngữ. Nhưng ngày nay, hợp ngữ hiếm khi được sử dụng ngoại trừ cho những mục đích rất đặc thù. Khi phần cứng được bổ sung vào bộ xử lý để triển khai pipelining—việc thực thi tuần tự nhiều mã lệnh cùng lúc—hợp ngữ đã trở nên phức tạp và khó nhằn hơn. Cùng lúc đó, các trình biên dịch lại trở nên tinh vi hơn. Dung lượng lưu trữ và bộ nhớ lớn hơn của máy tính hiện đại cũng góp phần vào xu hướng này: Lập trình viên không còn cảm thấy bị thôi thúc phải tạo ra những đoạn mã chạy vừa vặn trong một lượng bộ nhớ nhỏ bé và nhét vừa một chiếc đĩa mềm chật chội nữa.

Các nhà thiết kế những chiếc máy tính đời đầu đã cố gắng trình bày các bài toán cho máy bằng các ký hiệu đại số, nhưng trình biên dịch hoạt động được thực sự đầu tiên nhìn chung được cho là Arithmetic Language version 0 (hay A-0), được tạo ra cho máy UNIVAC bởi Grace Murray Hopper (1906–1992) tại Remington-Rand vào năm 1952. Tiến sĩ Hopper cũng là người đã đặt ra thuật ngữ "compiler". Bà bắt đầu làm quen với máy tính từ rất sớm khi làm việc cho Howard Aiken trên chiếc máy Mark I vào năm 1944. Đến tận khi ở độ tuổi tám mươi, bà vẫn miệt mài làm việc trong ngành công nghiệp máy tính để phụ trách mảng quan hệ công chúng cho Tập đoàn Digital Equipment (DEC).

Ngôn ngữ bậc cao lâu đời nhất vẫn còn được sử dụng cho đến ngày nay (mặc dù đã được sửa đổi rất nhiều qua nhiều năm) là FORTRAN. Nhiều ngôn ngữ máy tính đời đầu có những cái tên tự chế được viết hoa toàn bộ vì chúng là một dạng từ viết tắt. FORTRAN là sự kết hợp của ba chữ cái đầu của FORmula (Công thức) và bốn chữ cái đầu của TRANslation (Dịch). Nó được phát triển tại IBM cho dòng máy tính 704 vào giữa thập niên 1950. Trong nhiều năm liền, FORTRAN được coi là ngôn ngữ "chân ái" của các nhà khoa học và kỹ sư. Nó hỗ trợ tính toán dấu phẩy động rất rộng và thậm chí còn hỗ trợ cả số phức, là sự kết hợp giữa số thực và số ảo.

COBOL—viết tắt của COmmon Business Oriented Language (Ngôn ngữ Định hướng Doanh nghiệp Phổ thông)—là một ngôn ngữ lập trình lâu đời khác vẫn còn được dùng, chủ yếu trong các tổ chức tài chính. COBOL được tạo ra bởi một ủy ban gồm đại diện các ngành công nghiệp Mỹ và Bộ Quốc phòng Hoa Kỳ bắt đầu từ năm 1959, nhưng nó chịu ảnh hưởng từ các trình biên dịch sơ khai của Grace Hopper. Một phần mục đích thiết kế của COBOL là để các nhà quản lý, dù có thể không trực tiếp gõ mã, nhưng ít nhất cũng có thể đọc được mã chương trình và kiểm tra xem nó có đang làm đúng việc cần làm hay không. (Tất nhiên, trong đời thực, chuyện đó hiếm khi xảy ra).

Một ngôn ngữ lập trình cực kỳ có sức ảnh hưởng nhưng hiện không còn được sử dụng (ngoại trừ những ai đam mê) là ALGOL. ALGOL là viết tắt của ALGOrithmic Language, nhưng ALGOL cũng chia sẻ tên gọi với ngôi sao sáng thứ hai trong chòm sao Perseus. Được thiết kế ban đầu bởi một ủy ban quốc tế vào năm 1957 và 1958, ALGOL là tổ tiên trực tiếp của nhiều ngôn ngữ đa dụng phổ biến trong nửa thế kỷ qua. Nó đã khai phá ra một khái niệm sau này được biết đến là lập trình cấu trúc (structured programming). Thậm chí đến tận bây giờ, đôi khi người ta vẫn nhắc đến các ngôn ngữ lập trình "kiểu ALGOL".

ALGOL đã thiết lập nên các cấu trúc lập trình mà hiện nay đã trở thành tiêu chuẩn cho gần như mọi ngôn ngữ lập trình. Chúng gắn liền với các từ khóa nhất định, tức là những từ có ý nghĩa riêng trong ngôn ngữ để chỉ định các thao tác cụ thể. Nhiều câu lệnh được gộp lại thành khối (block), và được thực thi trong một số điều kiện nhất định hoặc với một số vòng lặp nhất định.

Lệnh if thực thi một câu lệnh hoặc một khối lệnh dựa trên một điều kiện logic—ví dụ: if biến height nhỏ hơn 55. Lệnh for thực thi một câu lệnh hoặc khối lệnh nhiều lần, thường dựa trên việc tăng giá trị của một biến. array (mảng) là tập hợp các giá trị cùng kiểu—ví dụ, tên của các thành phố. Các chương trình được tổ chức thành các khối và các hàm (functions).

Mặc dù các phiên bản của FORTRAN, COBOL và ALGOL đều có sẵn cho máy tính gia đình, nhưng không cái nào trong số chúng tạo ra ảnh hưởng lớn trên các cỗ máy nhỏ bằng BASIC.

BASIC (Beginner’s All-purpose Symbolic Instruction Code) được John Kemeny và Thomas Kurtz thuộc khoa Toán trường Dartmouth phát triển vào năm 1964 trong dự án hệ thống chia sẻ thời gian của trường. Hầu hết sinh viên tại Dartmouth không phải là sinh viên chuyên ngành toán hay kỹ thuật, vì vậy không thể mong đợi họ vật lộn với sự phức tạp của máy tính và cú pháp chương trình khó nhằn. Một sinh viên Dartmouth ngồi trước thiết bị đầu cuối có thể tạo ra một chương trình BASIC đơn giản bằng cách gõ các câu lệnh BASIC với các con số đứng trước. Các con số này chỉ ra thứ tự của các câu lệnh trong chương trình. Chương trình BASIC đầu tiên trong cuốn cẩm nang hướng dẫn BASIC đầu tiên được xuất bản là:

10 LET X = (7 + 8) / 3
20 PRINT X
30 END

Nhiều phiên bản BASIC sau này được triển khai dưới dạng trình thông dịch (interpreters) thay vì trình biên dịch (compilers). Trong khi trình biên dịch đọc một tệp mã nguồn và tạo ra một tệp mã máy thực thi, thì trình thông dịch đọc mã nguồn và thực thi nó trực tiếp mà không cần tạo ra tệp thực thi. Viết trình thông dịch dễ hơn viết trình biên dịch, nhưng thời gian chạy của chương trình thông dịch thường chậm hơn so với chương trình đã biên dịch. Trên máy tính gia đình, BASIC đã có một khởi đầu thuận lợi khi đôi bạn thân Bill Gates (sinh năm 1955) và Paul Allen (sinh năm 1953) viết một trình thông dịch BASIC cho máy Altair 8800 vào năm 1975 và khởi nghiệp công ty của họ, Microsoft Corporation.

Ngôn ngữ lập trình Pascal kế thừa phần lớn cấu trúc từ ALGOL nhưng cũng gom vài tính năng từ COBOL. Pascal được giáo sư khoa học máy tính người Thụy Sĩ Niklaus Wirth (sinh năm 1934) thiết kế vào cuối những năm 1960. Nó khá được lòng các lập trình viên IBM PC đời đầu, nhưng ở một phiên bản rất cụ thể: sản phẩm Turbo Pascal, do Borland International tung ra năm 1983 với cái giá hời 49,95 USD. Turbo Pascal do sinh viên người Đan Mạch Anders Hejlsberg (sinh năm 1960) viết và được tích hợp trọn gói với một môi trường phát triển tích hợp (integrated development environment - IDE). Trình soạn thảo văn bản và trình biên dịch được hợp nhất trong một chương trình duy nhất tạo điều kiện lập trình cực nhanh. Môi trường phát triển tích hợp vốn dĩ đã phổ biến trên các máy tính mainframe lớn, nhưng Turbo Pascal lại là sứ giả đổ bộ lên các cỗ máy nhỏ.

Pascal cũng ảnh hưởng nhiều đến Ada, một ngôn ngữ được phát triển để phục vụ cho Bộ Quốc phòng Hoa Kỳ. Ngôn ngữ này được đặt theo tên của Augusta Ada Byron, người đã xuất hiện ở Chương 15 trong vai trò người ghi chép về Analytical Engine của Charles Babbage.

Và tiếp đến là C, ngôn ngữ lập trình rất được yêu chuộng, được tạo ra từ khoảng năm 1969 đến 1973 chủ yếu bởi Dennis M. Ritchie tại Bell Telephone Laboratories. Mọi người thường hay hỏi tại sao ngôn ngữ này lại gọi là C. Câu trả lời rất đơn giản là nó bắt nguồn từ ngôn ngữ đời đầu tên là B, B lại là phiên bản rút gọn của BCPL (Basic CPL), còn BCPL lại có nguồn gốc từ CPL (Combined Programming Language).

Hầu hết các ngôn ngữ lập trình đều cố gắng loại bỏ những tàn dư của hợp ngữ chẳng hạn như địa chỉ bộ nhớ. Nhưng C thì không. C chứa một tính năng gọi là con trỏ (pointer), bản chất nó chính là một địa chỉ bộ nhớ. Con trỏ rất tiện lợi cho những lập trình viên biết cách sử dụng chúng, nhưng lại là quả bom nổ chậm với gần như tất cả những ai khác. Vì khả năng ghi đè lên các khu vực quan trọng của bộ nhớ, con trỏ là nguồn căn rất phổ biến của lỗi (bug). Lập trình viên Alan I. Holub thậm chí còn viết hẳn một cuốn sách về C mang tựa đề "Đủ Dây Để Tự Bắn Vào Chân Mình" (Enough Rope to Shoot Yourself in the Foot).

C đã trở thành cụ tổ của một loạt các ngôn ngữ an toàn hơn C và được bổ sung thêm các tính năng để làm việc với các đối tượng (objects), là các thực thể lập trình kết hợp cả mã và dữ liệu theo cách thức rất cấu trúc. Nổi tiếng nhất trong số này là C++, được nhà khoa học máy tính người Đan Mạch Bjarne Stroustrup (sinh năm 1950) tạo ra vào năm 1985; Java, do James Gosling (sinh năm 1955) thiết kế tại Tập đoàn Oracle vào năm 1995; và C#, do chính Anders Hejlsberg thiết kế tại Microsoft vào năm 2000. Tại thời điểm cuốn sách này được viết, một trong những ngôn ngữ lập trình được xài nhiều nhất là một ngôn ngữ chịu ảnh hưởng từ C khác có tên là Python, ban đầu do lập trình viên người Hà Lan Guido van Rossum (sinh năm 1956) thiết kế vào năm 1991. Nhưng nếu bạn đang đọc cuốn sách này vào những năm 2030 hay 2040, có thể bạn sẽ quen thuộc với những ngôn ngữ mà lúc này thậm chí còn chưa ra đời!

Mỗi ngôn ngữ lập trình bậc cao ép lập trình viên tư duy theo những cách khác nhau. Ví dụ, một số ngôn ngữ lập trình mới hơn tập trung vào việc thao tác với các hàm (functions) thay vì các biến. Chúng được gọi là các ngôn ngữ lập trình chức năng (functional), và với một lập trình viên đã quá quen thuộc với các ngôn ngữ thủ tục (procedural) truyền thống, thoạt đầu chúng có vẻ khá lạ. Tuy nhiên, chúng cung cấp những giải pháp thay thế có khả năng truyền cảm hứng để lập trình viên hoàn toàn chuyển hướng cách tiếp cận vấn đề của mình.
Nhưng dù là ngôn ngữ nào đi nữa, CPU vẫn cứ thực thi thứ mã máy cũ kỹ đó thôi.

Tuy nhiên, vẫn có những cách để phần mềm có thể xóa nhòa đi khoảng cách giữa nhiều loại CPU và mã máy gốc của chúng. Phần mềm có thể mô phỏng (emulate) các CPU khác nhau, cho phép con người chạy các phần mềm cũ và các trò chơi máy tính cổ lỗ sĩ trên các máy tính hiện đại. (Việc này cũng chẳng có gì mới mẻ: Khi Bill Gates và Paul Allen quyết định viết một trình thông dịch BASIC cho máy Altair 8800, họ đã test nó trên một chương trình mô phỏng Intel 8080 mà họ viết trên máy tính mainframe DEC PDP-10 tại Đại học Harvard.) Java và C# có thể được biên dịch thành một đoạn mã trung gian na ná mã máy, sau đó đoạn mã này sẽ được chuyển thành mã máy thực sự tại thời điểm chương trình được thực thi. Một dự án tên là LLVM đang được phát triển nhằm cung cấp một liên kết ảo giữa bất kỳ ngôn ngữ lập trình bậc cao nào và bất kỳ tập lệnh nào mà một CPU thực thi.

Đây là điều kì diệu của phần mềm. Với đủ bộ nhớ và tốc độ, bất kỳ máy tính kỹ thuật số nào cũng có thể làm được bất cứ điều gì mà bất kỳ máy tính kỹ thuật số nào khác có thể làm. Đó là hệ quả từ công trình về khả tính (computability) của Alan Turing vào những năm 1930.

Nhưng điều mà Turing cũng đã chứng minh là có những vấn đề thuật toán sẽ vĩnh viễn nằm ngoài tầm với của máy tính kỹ thuật số, và một trong những vấn đề này mang lại những hệ lụy giật mình: Bạn không thể viết một chương trình máy tính để xác định xem một chương trình máy tính khác có đang hoạt động chính xác hay không! Điều này có nghĩa là chúng ta sẽ chẳng bao giờ có thể dám chắc 100% rằng các chương trình của mình đang hoạt động đúng như mong muốn.

Đây là một thực tế khắc nghiệt, và đó là lý do tại sao việc kiểm thử (testing) và gỡ lỗi (debugging) mở rộng lại là một phần quan trọng trong quy trình phát triển phần mềm.

Một trong những ngôn ngữ mang âm hưởng C thành công nhất là JavaScript, ban đầu do Brendan Eich (sinh năm 1961) thiết kế tại Netscape và xuất hiện lần đầu vào năm 1995. JavaScript là ngôn ngữ mà các trang web sử dụng để cung cấp các tính năng tương tác vượt xa việc trình bày văn bản và hình ảnh tĩnh do HTML (Hypertext Markup Language) đảm nhiệm. Tại thời điểm cuốn sách này được viết, gần 98% trong số 10 triệu trang web hàng đầu sử dụng ít nhất một phần JavaScript.

Tất cả các trình duyệt web phổ biến ngày nay đều hiểu JavaScript, điều đó có nghĩa là bạn có thể bắt tay vào viết các chương trình JavaScript ngay trên máy tính để bàn hoặc laptop mà chẳng cần tải hay cài đặt thêm bất kỳ công cụ lập trình nào khác.

Vậy... bạn có muốn tự mình thử nghiệm một chút với JavaScript không?

Tất cả những gì bạn cần làm là tạo một tệp HTML chứa một ít mã JavaScript bằng cách sử dụng phần mềm Notepad trên Windows hoặc TextEdit trên macOS. Bạn lưu nó thành một tệp rồi mở nó bằng trình duyệt web yêu thích của mình, như Edge, Chrome, hoặc Safari.

Trên Windows, hãy mở chương trình Notepad. (Bạn có thể cần phải tìm nó bằng tính năng Search trên menu Start). Nó đang đợi bạn gõ vào đấy.

Trên macOS, hãy mở chương trình TextEdit. (Bạn có thể cần tìm nó bằng Spotlight Search). Trên màn hình đầu tiên hiện ra, hãy bấm nút New Document. TextEdit được thiết kế mặc định để tạo ra tệp văn bản giàu định dạng (rich-text) chứa cả các thông tin định dạng chữ. Bạn không muốn thứ đó. Bạn cần một tệp văn bản thuần túy (plain text), do đó, trong menu Format, hãy chọn Make Plain Text. Đồng thời, trong phần Spelling and Grammar của menu Edit, hãy bỏ chọn các tùy chọn tự động kiểm tra và sửa lỗi chính tả.

Giờ hãy gõ đoạn sau vào:
<html>
    <head>
        <title>My JavaScript</title>
    </head>
    <body>
        <p id="result">Kết quả chương trình sẽ hiện ở đây!</p>
        <script>
            // JavaScript programs go here
        </script>
    </body>
</html>

Đây là HTML, ngôn ngữ này xoay quanh các thẻ (tags) bọc lấy nhiều phần khác nhau của tệp. Toàn bộ tệp bắt đầu bằng thẻ <html> và kết thúc bằng thẻ </html> bao bọc mọi thứ còn lại. Bên trong đó, phần <head> bọc một thẻ <title> để hiển thị tiêu đề ở trên cùng của trang web. Phần <body> bọc một thẻ <p> ("paragraph" - đoạn văn) kèm theo dòng chữ "Kết quả chương trình sẽ hiện ở đây!".

Phần <body> cũng bọc một phần <script>. Đó sẽ là nơi cư ngụ của các chương trình JavaScript. Hiện tại đã có sẵn một chương trình nhỏ ở đó, chỉ bao gồm một dòng bắt đầu bằng hai dấu gạch chéo. Hai dấu gạch chéo đó báo hiệu rằng dòng này là một chú thích (comment). Mọi thứ nối đuôi sau hai dấu gạch chéo cho đến hết dòng đều chỉ dành cho người đọc chương trình. Nó bị bỏ qua khi JavaScript được thực thi.

Khi bạn gõ những dòng này vào Notepad hoặc TextEdit, bạn không cần phải thụt lề mọi thứ giống như tôi đã làm. Bạn thậm chí viết hết lên cùng một dòng. Nhưng để giữ cho đầu óc minh mẫn, hãy đặt các thẻ <script></script> trên các dòng riêng biệt nhé.

Giờ hãy lưu tệp ở đâu đó: Trong Notepad hoặc TextEdit, chọn Save từ menu File. Chọn một vị trí để lưu tệp; Desktop của máy tính là tiện nhất. Đặt tên tệp là MyJavaScriptExperiment.html hoặc gì đó đại loại. Phần đuôi tệp nằm sau dấu chấm là cực kỳ quan trọng. Hãy đảm bảo nó là html. TextEdit sẽ hỏi lại để xác nhận xem đó có đúng là những gì bạn muốn không. Đúng vậy đấy!

Sau khi lưu tệp, đừng đóng Notepad hoặc TextEdit vội. Hãy cứ để đó để chỉnh sửa sau.

Giờ hãy tìm tệp bạn vừa lưu và nhấp đúp vào nó. Windows hoặc macOS sẽ nạp nó vào trình duyệt web mặc định. Tiêu đề của trang web sẽ là "My JavaScript" và ở góc trên bên trái của trang web sẽ có dòng chữ "Kết quả chương trình sẽ hiện ở đây!". Nếu không thấy, hãy kiểm tra lại xem mọi thứ đã được gõ vào tệp đúng như hướng dẫn chưa.

Quy trình để thử nghiệm với JavaScript là như sau: Trong Notepad hoặc TextEdit, bạn nhập một ít mã JavaScript vào giữa các thẻ <script></script> rồi lại lưu tệp đó. Rồi quay sang trình duyệt web và tải lại (refresh) trang, thường là bằng cách nhấp vào biểu tượng mũi tên cuộn tròn. Bằng cách này, bạn có thể chạy một chương trình JavaScript khác hoặc một phiên bản thay đổi của chương trình chỉ với hai bước: Lưu phiên bản mới của tệp; sau đó làm mới trang trên trình duyệt web.

Đây là một chương trình đầu tay khá ổn để bạn gõ vào khu vực giữa các thẻ <script></script>:

let message = "Lời chào từ chương trình Javasciprt!";
document.getElementById("result").innerHTML = message;

Chương trình này chứa hai câu lệnh, mỗi câu nằm trên một dòng riêng biệt và kết thúc bằng dấu chấm phẩy.

Trong câu lệnh đầu tiên, từ let là một từ khóa của JavaScript (nghĩa là nó là một từ đặc biệt mang một ý nghĩa cụ thể trong JavaScript), và message là một biến. Bạn có thể dùng từ khóa let để thiết lập biến với một giá trị, và sau này bạn có thể gán nó cho một giá trị khác. Bạn không bắt buộc phải dùng từ message. Bạn có thể xài msg hay bất kỳ từ nào khác bắt đầu bằng một chữ cái, không chứa khoảng trắng hoặc dấu câu. Trong chương trình này, biến message được gán bằng một chuỗi các ký tự bắt đầu và kết thúc bằng cặp dấu ngoặc kép. Bạn có thể chèn bất kỳ thông điệp nào bạn muốn vào giữa hai dấu ngoặc kép đó.

Câu lệnh thứ hai hẳn là trúc trắc và khó hiểu hơn, nhưng nó cần thiết để JavaScript tương tác với HTML. Từ khóa document tham chiếu đến trang web. Bên trong trang web đó, getElementById sẽ tìm kiếm một phần tử HTML có tên là "result". Đó chính là thẻ <p>, và innerHTML nghĩa là đặt nội dung của biến message vào giữa các thẻ <p></p> như thể bạn đã gõ nó vào ngay từ đầu.

Câu lệnh thứ hai này dài và rối rắm là bởi JavaScript phải có khả năng truy cập hoặc thay đổi bất cứ thứ gì trên trang web, do đó nó phải đủ linh hoạt để làm được điều đó.

Các trình biên dịch và thông dịch soi lỗi chính tả còn khắt khe hơn cả mấy ông bà giáo dạy văn ngày xưa, thế nên hãy nhớ gõ câu lệnh thứ hai y hệt như đã chỉ dẫn nhé! JavaScript là ngôn ngữ phân biệt chữ hoa chữ thường (case-sensitive). Hãy đảm bảo bạn gõ innerHTML cho thật chuẩn; viết thành InnerHTML hay innerHtml đều hư hết! Đó cũng là lý do tại sao bạn cần phải tắt tính năng tự động sửa lỗi chính tả trong ứng dụng TextEdit của macOS. Kẻo TextEdit sẽ tài lanh sửa let thành Let, và thế là hỏng bét.

Khi bạn lưu phiên bản mới này của tệp và làm mới trang trong trình duyệt web, bạn sẽ thấy thông điệp đó xuất hiện ở góc trên bên trái. Nếu không, hãy check lại bài làm của mình nhé!

Hãy thử một chương trình đơn giản khác dùng chung tệp đó. Nếu bạn không nỡ xóa chương trình vừa viết, hãy nhét nó vào giữa hai chuỗi ký hiệu đặc biệt này:

/*
let message = "Hello from my JavaScript program!";
document.getElementById("result").innerHTML = message;
*/

Đối với JavaScript, bất cứ thứ gì nằm giữa /**/ đều được xem là chú thích (comment) và bị lờ đi. Giống như nhiều ngôn ngữ mang âm hưởng của C, JavaScript có hai kiểu ghi chú thích: chú thích nhiều dòng dùng /**/, và chú thích một dòng dùng //.

Chương trình tiếp theo sẽ pha chút tính toán:

let a = 535.43;
let b = 289.771;
let c = a * b;
document.getElementById("result").innerHTML = c;

Giống như trong nhiều ngôn ngữ lập trình khác, phép nhân được quy định bằng dấu sao (*) thay vì dấu nhân truyền thống vì cái dấu nhân chuẩn đó không có trong bảng mã ký tự ASCII.

Lưu ý rằng câu lệnh cuối cùng cũng giống như chương trình trước, ngoại trừ việc phần inner HTML giữa các thẻ <p> giờ đây đang được thiết lập thành biến c, vốn là tích của hai số. JavaScript chẳng buồn quan tâm bạn thiết lập inner HTML thành một chuỗi ký tự hay một con số. Nó sẽ tự xoay xở để hiển thị kết quả.

Một trong những tính năng quan trọng nhất của ngôn ngữ bậc cao là vòng lặp. Bạn đã thấy cách thực hiện vòng lặp trong hợp ngữ bằng lệnh JMP và lệnh nhảy có điều kiện. Một số ngôn ngữ bậc cao bao gồm một câu lệnh gọi là goto cực kỳ giống với lệnh nhảy. Nhưng các lệnh goto thường không được khuyến khích sử dụng ngoại trừ những trường hợp đặc thù. Một chương trình đòi hỏi quá nhiều lệnh nhảy sẽ nhanh chóng trở thành thảm họa khó bề quản lý. Cụm từ chuyên môn gọi nó là "mã mì Ý" (spaghetti code), vì các bước nhảy cứ đan chéo rối nùi vào nhau. Cũng chính vì lý do này, JavaScript thậm chí còn chẳng triển khai lệnh goto.

Các ngôn ngữ lập trình bậc cao hiện đại quản lý vòng lặp mà không phải nhảy loạn khắp nơi. Giả sử bạn muốn cộng tất cả các số từ 1 đến 100. Đây là một cách để viết bằng vòng lặp JavaScript:

let total = 0;
let number = 1;

while (number <= 100)
{
    total = total + number;
    number = number + 1;
}

document.getElementById("result").innerHTML = total;

Đừng bận tâm mấy dòng trống. Tôi dùng chúng để phân tách các phần khác nhau của chương trình cho rõ ràng thôi. Chương trình bắt đầu bằng phần khởi tạo, nơi hai biến được gán cho các giá trị ban đầu. Vòng lặp bao gồm câu lệnh while và khối mã nằm giữa các dấu ngoặc nhọn. Nếu biến number nhỏ hơn hoặc bằng 100, khối mã sẽ được thực thi. Nó sẽ cộng number vào total và tăng number lên 1. Khi number lớn hơn 100, chương trình sẽ tiếp tục với câu lệnh nằm sau dấu ngoặc nhọn phải. Câu lệnh đó hiển thị kết quả.

Có thể bạn sẽ hơi khó hiểu nếu gặp một bài toán đại số với hai câu lệnh này:

total = total + number;
number = number + 1;

Làm sao total lại có thể bằng total cộng với number? Chẳng phải điều đó có nghĩa number bằng 0 sao? Và làm thế nào number lại có thể bằng number cộng với một?

Trong JavaScript, dấu bằng không biểu thị đẳng thức (equality). Thay vào đó, nó là một toán tử gán (assignment). Biến ở bên trái dấu bằng được gán cho giá trị tính toán được ở bên phải dấu bằng. Nói cách khác, giá trị bên phải dấu bằng "chui vào" biến bên trái. Trong JavaScript (cũng như trong C), việc kiểm tra xem hai biến có bằng nhau hay không phải dùng tới hai dấu bằng (==).

Đối với hai câu lệnh này, JavaScript hỗ trợ một vài lối viết tắt mượn từ C. Chúng có thể được viết gọn lại như sau:

total += number;
number += 1;

Sự kết hợp của dấu cộng và dấu bằng có nghĩa là cộng giá trị bên phải vào biến bên trái.

Chuyện các biến được tăng thêm 1 là rất phổ biến, giống như biến number ở đây, vì vậy câu lệnh để tăng biến number có thể được viết tắt như này:

number++;

Còn hơn nữa, có thể gộp chúng lại thành một câu:

total += number++;

Giá trị của number được cộng vào total và ngay sau đó number tăng thêm 1! Nhưng cách viết này có thể hơi mù mờ và khó hiểu với những ai chưa sành sỏi lập trình như bạn, nên có thể bạn sẽ tránh dùng nó.

Một cách phổ biến khác để viết chương trình này là dùng một vòng lặp dựa trên từ khóa for:

let total = 0;

for (let number = 1; number <= 100; number++)
{
    total += number;
}

document.getElementById("result").innerHTML = total;

Câu lệnh for chứa ba mệnh đề (clause) phân tách nhau bởi dấu chấm phẩy: Mệnh đề đầu tiên khởi tạo biến number với 1. Khối mã bên trong dấu ngoặc nhọn chỉ được thực thi nếu mệnh đề thứ hai là đúng—nghĩa là nếu number nhỏ hơn hoặc bằng 100. Sau khi khối mã đó được thực thi, number được tăng lên. Hơn nữa, vì khối mã chỉ chứa đúng một câu lệnh, nên ta hoàn toàn có thể bỏ dấu ngoặc nhọn.

Đây là một chương trình nhỏ thực hiện vòng lặp đi qua các số từ 1 đến 100 và hiển thị căn bậc hai của chúng:

for (let number = 1; number <= 100; number++)
{
    document.getElementById("result").innerHTML +=
        "The square root of " + number + " is " +
        Math.sqrt(number) + "<br />";
}

Khối mã thực thi bên trong vòng lặp chỉ có đúng một câu lệnh, nhưng vì câu lệnh quá dài nên tôi đã tách nó ra thành ba dòng. Lưu ý rằng dòng đầu tiên trong ba dòng này kết thúc bằng +=, nghĩa là những gì theo sau sẽ được cộng dồn vào inner HTML của thẻ <p>, tạo thêm văn bản mới ở mỗi lần lặp. Những gì được cộng vào inner HTML là một sự pha trộn giữa văn bản và số. Hãy đặc biệt chú ý tới Math.sqrt, đây là một hàm JavaScript có chức năng tính căn bậc hai. Nó là một phần của ngôn ngữ JavaScript. (Hàm kiểu này đôi khi được gọi là hàm tích hợp - builtin). Cũng hãy chú ý tới thẻ <br />, đó là thẻ ngắt dòng trong HTML.

Khi chương trình chạy xong, bạn sẽ thấy một danh sách dài thòng lòng. Chắc là bạn phải cuộn xuống mới xem hết được!

Chương trình tiếp theo mà tôi sẽ trình diễn ở đây triển khai một thuật toán nức tiếng chuyên dùng để tìm số nguyên tố, được gọi là Sàng Eratosthenes (sieve of Eratosthenes). Eratosthenes (176–194 TCN) từng là thủ thư của thư viện huyền thoại ở Alexandria và cũng được lưu danh sử sách vì đã tính toán chuẩn xác chu vi trái đất.

Số nguyên tố là những số nguyên dương chỉ chia hết cho 1 và chính nó. Số nguyên tố đầu tiên là 2 (cũng là số nguyên tố chẵn duy nhất), và các số nguyên tố tiếp theo là 3, 5, 7, 11, 13, 17, 19, 23, 29, vân vân và mây mây.

Kỹ thuật của Eratosthenes bắt đầu bằng một danh sách các số nguyên dương khởi đầu từ 2. Bởi vì 2 là một số nguyên tố, hãy gạch bỏ tất cả các số là bội số của 2. (Tức là toàn bộ các số chẵn trừ số 2). Những số đó không phải là số nguyên tố. Bởi vì 3 là một số nguyên tố, hãy gạch bỏ tất cả các bội số của 3. Ta đã biết 4 không phải là số nguyên tố vì nó đã bị gạch bỏ. Số nguyên tố tiếp theo là 5, vậy nên hãy gạch bỏ tất cả các bội số của 5. Cứ tiếp tục theo cách này. Những gì còn sót lại cuối cùng chính là các số nguyên tố.

Chương trình JavaScript thực hiện thuật toán này sử dụng một thực thể lập trình rất phổ biến được gọi là mảng (array). Mảng khá giống với biến ở chỗ nó có tên, nhưng mảng lưu trữ nhiều phần tử, và mỗi phần tử được tham chiếu bằng một chỉ số (index) nằm trong cặp dấu ngoặc vuông theo sau tên mảng.

Mảng trong chương trình này tên là primes, và nó chứa 10.000 giá trị Boolean. Trong JavaScript, các giá trị Boolean chỉ có thể là true (đúng) hoặc false (sai), và chúng cũng là các từ khóa của JavaScript. (Bạn hẳn đã quen với cái khái niệm này từ Chương 6 rồi!)

Dưới đây là cách chương trình tạo ra mảng tên primes và cách nó đặt mọi giá trị ban đầu của mảng là true:

let primes = [];
for (let index = 0; index < 10000; index++)
{
    primes.push(true);
}

Có một cách ngắn gọn hơn để làm việc này, nhưng hơi mù mờ:

let primes = new Array(10000).fill(true);

Phần tính toán cốt lõi có sự góp mặt của hai vòng lặp for, vòng này nằm trong vòng kia. (Vòng lặp for thứ hai được coi là bị lồng (nested) vào vòng lặp thứ nhất). Ta cần tới hai biến để chỉ định chỉ số cho mảng, và thay vì dùng các biến thể của từ index, tôi đã xài i1i2 cho ngắn gọn. Tên biến có thể chứa số, nhưng bắt buộc phải bắt đầu bằng chữ cái:

for (let i1 = 2; i1 <= 100; i1++)
{
    if (primes[i1])
    {
        for (let i2 = 2; i2 < 10000 / i1; i2++)
        {
            primes[i1 * i2] = false;
        }
    }
}

Vòng lặp for đầu tiên tăng biến i1 từ 2 đến 100, tức là căn bậc hai của 10.000. Câu lệnh if chỉ thực thi phần tiếp theo nếu phần tử mảng đó là true, báo hiệu rằng nó là một số nguyên tố. Vòng lặp thứ hai bắt đầu tăng biến i2 từ 2. Do đó, tích của i1i2 sẽ là 2 lần i1, 3 lần i1, 4 lần i1, cứ thế, và những số đó rõ ràng không phải là số nguyên tố, nên phần tử mảng tương ứng sẽ bị ép về false.

Nghe có vẻ hơi kì khi chỉ tăng i1 lên đến 100, và i2 lên tới 10.000 chia cho i1, nhưng nhiêu đó là quá đủ để tóm toàn bộ các số nguyên tố lên tới 10.000 rồi.

Khúc cuối của chương trình sẽ lo việc hiển thị kết quả:

for (let index = 2; index < 10000; index++)
{ 
    if (primes[index])
    {
        document.getElementById("result").innerHTML += 
            index + " ";
    }
}

Nếu JavaScript khơi dậy niềm đam mê trong bạn, thì xin đừng xài tiếp Notepad hay TextEdit nữa! Ở ngoài kia có cả tá công cụ xịn sò hơn sẽ luôn nhắc cho bạn biết khi gõ sai một từ hay mắc lỗi ở đâu đó.

Nếu bạn muốn nghiên cứu vài chương trình JavaScript đơn giản đã được chú thích như một bài hướng dẫn, hãy ghé phần dành cho chương này trên trang CodeHiddenLanguage.com.

Đôi khi người ta cứ mãi tranh cãi xem lập trình rốt cuộc là một môn nghệ thuật hay một ngành khoa học. Một mặt, các chương trình giảng dạy ở trường đại học được gọi chung là Khoa học Máy tính (Computer Science), nhưng mặt khác, bạn lại bắt gặp những bộ sách như series The Art of Computer Programming lừng danh của Donald Knuth. Lập trình sở hữu yếu tố của cả khoa học lẫn nghệ thuật, nhưng thực chất nó lại là một thứ gì đó hoàn toàn khác. "Thay vào đó," nhà vật lý Richard Feynman từng viết, "khoa học máy tính giống như kỹ thuật—nó xoay quanh việc làm sao để một thứ gì đó làm được một việc gì đó."

Và rất nhiều khi, đó là một cuộc chiến đầy gian nan. Như bạn có thể đã thấm thía, mắc lỗi trong các chương trình máy tính dễ như ăn kẹo, và việc tiêu tốn ngày giờ để truy lùng dấu vết lỗi cũng chẳng phải chuyện hiếm. Việc gỡ lỗi (debugging) tự thân nó đã là một môn nghệ thuật (hay khoa học, hay một kỳ công của ngành kỹ thuật) rồi.

Những gì bạn vừa chứng kiến chỉ là bề nổi của tảng băng chìm trong thế giới lập trình JavaScript. Mà lịch sử đã dạy chúng ta là phải luôn dè chừng mấy tảng băng chìm đấy! Thi thoảng, bản thân chiếc máy tính lại làm ra những trò bất ngờ. Ví dụ, hãy thử chạy chương trình JavaScript nhỏ này:

let a = 55.2;
let b = 27.8;
let c = a * b;
document.getElementById("result").innerHTML = c;

Kết quả mà chương trình này hiển thị là 1534.5600000000002, trông chả đúng, và không đúng thật. Kết quả chuẩn phải là 1534.56.

Chuyện gì đã xảy ra?

Vì số thực dấu phẩy động (floating-point numbers) đóng vai trò sống còn trong điện toán, nên một tiêu chuẩn đã được Viện Kỹ sư Điện và Điện tử (IEEE) thiết lập vào năm 1985, và cũng được Viện Tiêu chuẩn Quốc gia Hoa Kỳ (ANSI) công nhận. ANSI/IEEE Std 754-1985 có tên gọi là Tiêu chuẩn IEEE về Số học Dấu Phẩy Động Nhị phân (IEEE Standard for Binary Floating-Point Arithmetic). Xét về độ dài của các bộ tiêu chuẩn thì cái này chẳng thấm vào đâu—chỉ vỏn vẹn 18 trang—nhưng nó đã mổ xẻ cặn kẽ chi tiết cách thức mã hóa số dấu phẩy động một cách thuận tiện nhất. Nó là một trong những chuẩn mực quan trọng nhất của toàn ngành điện toán và được hầu hết mọi máy tính và chương trình máy tính hiện đại mà bạn sẽ gặp.

Tiêu chuẩn dấu phẩy động IEEE quy định hai định dạng cơ bản: độ chính xác đơn (single precision), yêu cầu 4 byte cho mỗi số, và độ chính xác kép (double precision), yêu cầu 8 byte cho mỗi số. Vài ngôn ngữ lập trình cho bạn quyền chọn xem nên xài loại nào; còn JavaScript thì một lòng một dạ chỉ xài độ chính xác kép.

Tiêu chuẩn IEEE dựa trên việc biểu diễn số bằng ký hiệu khoa học (scientific notation), trong đó một số được chia làm hai nửa: một phần định trị (significand hay mantissa) được nhân với 10 mũ một số nguyên gọi là số mũ (exponent):

42.705,7846 = 4,27057846 x 10^4

Kiểu biểu diễn cụ thể này được nhắc đến như một định dạng chuẩn hóa (normalized) vì phần mantissa chỉ có đúng một chữ số nằm bên trái dấu phẩy thập phân.

Tiêu chuẩn IEEE cũng biểu diễn các số dấu phẩy động y đúc, nhưng là trong hệ nhị phân. Mọi con số nhị phân mà bạn đã thấy trong cuốn sách này cho đến giờ đều là số nguyên, nhưng ta cũng hoàn toàn có thể dùng ký hiệu nhị phân cho các số thập phân có phần lẻ. Lấy ví dụ, xem xét số nhị phân sau:

101,1101

Đừng gọi dấu phẩy đó là "dấu phẩy thập phân" nhé! Vì đây là một số nhị phân, cái dấu đó phải được gọi là dấu phẩy nhị phân (binary point). Các chữ số nằm bên trái dấu phẩy nhị phân hợp thành phần nguyên, còn các chữ số nằm bên phải dấu phẩy nhị phân hợp thành phần lẻ.

Khi chuyển đổi từ hệ nhị phân sang thập phân ở Chương 10, bạn đã thấy cách các chữ số tương ứng với các lũy thừa của 2. Các chữ số ở bên phải dấu phẩy nhị phân cũng tương tự ngoại trừ việc chúng tương ứng với các lũy thừa âm của 2. Số nhị phân 101,1101 có thể được chuyển sang thập phân bằng cách nhân các bit với lũy thừa âm và dương tương ứng của 2 từ trái sang phải:

1 x 2^2. +
0 x 2^1  +
1 x 2^0  +
1 x 2^-1 +
1 x 2^-2 +
1 x 2^-3 +
1 x 2^-4

Lũy thừa âm của hai có thể được tính toán bằng cách bắt đầu từ 1 và liên tục chia cho 2:

1 x 4     +
0 x 2     +
1 x 1     +
1 x 0,5   +
1 x 0,25  +
0 x 0,125 +
1 x 0,0625

Theo phép tính này, giá trị thập phân tương đương của 101,11015,8125.

Trong dạng chuẩn hóa của ký hiệu khoa học thập phân, phần định trị chỉ có một chữ số nằm bên trái dấu phẩy thập phân. Tương tự, trong ký hiệu khoa học nhị phân, phần định trị chuẩn hóa cũng chỉ có một chữ số duy nhất nằm bên trái dấu phẩy nhị phân. Con số 101.1101 được thể hiện là:

1,011101 x 2^2

Một hệ quả từ quy tắc này là một số thực dấu phẩy động nhị phân chuẩn hóa sẽ luôn luôn có một số 1 (và không gì khác) ở bên trái dấu phẩy nhị phân.

Tiêu chuẩn IEEE cho một số thực dấu phẩy động độ chính xác kép đòi hỏi 8 byte. Tổng cộng 64 bit được phân bổ như sau:

  • s = 1 bit dấu (Sign)
  • e = 11 bit số mũ (Exponent)
  • f = 52 bit phần phân số của phần định trị (Significand Fraction)

Bởi vì phần định trị của một số thực dấu phẩy động nhị phân chuẩn hóa luôn có một số 1 ở phía bên trái dấu phẩy nhị phân, bit đó không được đưa vào bộ nhớ khi lưu trữ các số dấu phẩy động trong định dạng IEEE. Phần phân số 52-bit của phần định trị là phần duy nhất được lưu. Do đó, mặc dù chỉ có 52 bit được dùng để lưu phần định trị, độ chính xác (precision) vẫn được cho là 53 bit. Bạn sẽ hiểu được cảm giác độ chính xác 53-bit mang ý nghĩa thế nào ngay sau đây thôi.

Phần số mũ 11-bit có thể dao động từ 0 đến 2047. Đây được gọi là số mũ biased, bởi vì một số gọi là bias phải được trừ đi khỏi phần số mũ để ra được số mũ có dấu thực sự được dùng đến. Đối với các số thực dấu phẩy động độ chính xác kép, bias là 1023.

Số được đại diện bởi các giá trị s (bit dấu), e (số mũ), và f (phần phân số của phần định trị) là:

(-1)^s x 1.f x 2^{e - 1023}

Âm 1 mũ s kia là một cách nói cực kỳ vòng vo của dân toán học, nhằm truyền đạt: "Nếu s là 0, số đó là số dương (vì bất kỳ số nào mũ 0 cũng bằng 1); và nếu s là 1, đó là số âm (vì -1 mũ 1 là -1)."

Phần kế tiếp của biểu thức là 1.f, có nghĩa là số 1 theo sau bởi một dấu phẩy nhị phân, rồi tới 52 bit phần phân số của định trị. Cụm này sẽ nhân với 2 mũ một lũy thừa nào đó. Số mũ chính là phần số mũ bias 11-bit lưu trong bộ nhớ trừ đi 1023.

Tôi đang lơ đi vài tiểu tiết. Lấy ví dụ những gì tôi vừa mô tả, rõ ràng là không có cách nào biểu diễn số 0! Đây là ca đặc biệt, nhưng tiêu chuẩn IEEE cũng đủ sức bao dung cho cả số 0 âm (dùng để diễn tả những số âm rất bé), vô cực dương và vô cực âm, và một giá trị mang tên NaN, viết tắt của "Not a Number" (Không là số). Những ca đặc biệt này đóng vai trò sống còn của chuẩn dấu phẩy động.

Số 101,1101 mà tôi lấy làm ví dụ lúc nãy sẽ được lưu với một mantissa 52-bit là:

0111 0100 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Tôi đã cố tình rải khoảng trắng sau mỗi bốn chữ số để bạn dễ đọc hơn. Số mũ bias là 1025, vì vậy số đó là:

1,011101 x 2^{1025-1023} = 1,011101 x 2^2

Không tính số 0, số thực dấu phẩy động độ chính xác kép dương hoặc âm nhỏ nhất có thể là:

1,0000000000000000000000000000000000000000000000000000 x 2^-1022

Đó là 52 số 0 theo sau dấu phẩy nhị phân. Còn số lớn nhất là:

1,1111111111111111111111111111111111111111111111111111 x 2^1023

Biên độ trong hệ thập phân xấp xỉ từ 2,2250738585072014 x 10^308 đến 1,7976931348623158 x 10^308. Mười mũ 308 bự chà bá. Nó là 1 với 308 số 0 theo sau.

53 bit của phần định trị (tính luôn cả cái bit 1 bị giấu đi) mang lại độ phân giải tương đương xấp xỉ 16 chữ số thập phân, nhưng nó cũng có giới hạn của nó. Ví dụ, hai số 140.737.488.355.328,00 và 140.737.488.355.328,01 được lưu y như nhau. Trong chương trình máy tính của bạn, hai số này là một.

Thêm một vấn đề khác là đại đa số phân số thập phân đều không được lưu chính xác. Lấy ví dụ số thập phân 1,1. Nó được lưu với mantissa 52-bit là:

0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

Đó là phần lẻ nằm phía tay phải dấu phẩy nhị phân. Trọn bộ số nhị phân cho 1,1 thập phân là đống này:

1,0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

Nếu bạn hì hục ngồi đổi số này ra thập phân, thì sẽ bắt đầu thế này:

1 + 2^-3 + 2^-4 + 2^-7 + 2^-8 +2^-11 + ...

Tương đương với:

1 + 0,0625 + 0,03125 + 0,00390625 + 0,001953125 + 0,000244140625 + ...

Và rồi cuối cùng bạn sẽ tá hỏa nhận ra nó không hề bằng 1.1 thập phân mà lại bằng:

1,10000000000000008881...

Và một khi bạn đã lỡ thực hiện các phép toán số học trên những con số không được biểu diễn chính xác, thì dĩ nhiên bạn sẽ đạt những kết quả sai bét nhè. Và đó là lý do tại sao JavaScript lại cho nhân 55.2 với 27.8 được 1534,5600000000002.

Chúng ta đã quá quen với việc mọi số tồn tại trong một chuỗi liên tục không có kẻ hở nào. Tuy nhiên vì gò bó mà máy tính bắt buộc phải lưu các giá trị rời rạc. Ngành toán học rời rạc (discrete mathematics) chính là bệ đỡ lý thuyết cho mảng toán học của máy tính kỹ thuật số.

Một tầng rắc rối nữa trong số học dấu phẩy động nằm ở việc tính toán mấy thứ vui vui như căn bậc, số mũ, logarit, hay các hàm lượng giác. Nhưng mọi trò đó đều có thể được giải quyết êm đẹp bằng bốn phép toán dấu phẩy động cơ bản: cộng, trừ, nhân, và chia.

Tỉ dụ như, hàm lượng giác sin có thể được tính toán qua một chuỗi khai triển như thế này:

sin(x) = x - (x^3)/3! + (x^5)/5 - (x^7)/7! + ...

Đối số x bắt buộc phải tính bằng radian, một vòng tròn 360 độ thì có 2π radian. Dấu chấm than là ký hiệu của giai thừa. Nó hàm ý rằng phải nhân mọi số nguyên từ 1 tới số bị gắn dấu chấm than đó. Ví dụ, 5! sẽ là 1 x 2 x 3 x 4 x 5. Chỉ là phép nhân. Số mũ trong mỗi số hạng cũng là phép nhân nốt. Phần còn lại chỉ là chia, cộng, và trừ.

Phần kinh dị duy nhất chính là dấu ba chấm ở cuối, ám chỉ việc bạn phải tiếp tục các phép tính đó mãi mãi. Nhưng ở thế giới thực, nếu bạn tự "khoanh vùng" bản thân trong khoảng từ 0 đến π/2 (từ đó mọi giá trị sin khác đều có thể được nội suy ra), bạn chả cần tới mức mãi mãi đâu. Chỉ sau tầm một tá số hạng, bạn đã đạt tới mức chính xác ở độ phân giải 53-bit của số có độ chính xác kép rồi.

Dĩ nhiên, máy tính sinh ra là để làm mọi thứ trở nên dễ dàng với con người, thành thử ra việc tự viết một đống hàm để làm số học dấu phẩy động có vẻ đi ngược lại với tôn chỉ đó. Thế nhưng đó lại là vẻ đẹp của phần mềm. Một khi đã có ai đó hi sinh thân mình viết ra các đoạn mã dấu phẩy động cho một cỗ máy cụ thể, thì mọi người xung quanh cứ thế mà xài. Số học dấu phẩy động sắm một vai trò mang tính sống còn đối với các ứng dụng khoa học và kỹ thuật, nên theo truyền thống nó luôn được ưu ái dành cho vị trí ưu tiên hàng đầu. Ở cái thuở ban đầu của máy tính, việc viết các hàm dấu phẩy động luôn là một trong những công việc phần mềm "khai trương" ngay sau khi chế tạo xong một loại máy tính mới. Các ngôn ngữ lập trình thường được "trang bị tận răng" những thư viện với đủ thứ hàm toán học. Bạn đã được thấy hàm Math.sqrt của Javascript rồi đó.

Nó cũng cực kỳ hợp lý khi người ta thiết kế hẳn một phần cứng chuyên dụng cho các phép tính dấu phẩy động trực tiếp. Chiếc máy tính thương mại đầu tiên mang theo phần cứng dấu phẩy động dưới dạng tùy chọn chính là IBM 704 vào năm 1954. Con 704 này lưu mọi số dưới dạng giá trị 36-bit. Dành riêng cho các số dấu phẩy động, nó được chia ra thành một phần định trị 27-bit, một số mũ 8-bit, và một bit dấu. Phần cứng dấu phẩy động có thể cáng đáng phép cộng, trừ, nhân, và chia. Các hàm dấu phẩy động khác thì phải nhờ phần mềm triển khai.

Phần cứng tính toán dấu phẩy động chính thức đổ bộ lên máy tính để bàn vào năm 1980, thời điểm Intel tung ra con chip Bộ Đồng Xử Lý Dữ Liệu Số (Numeric Data Coprocessor) 8087, một loại mạch tích hợp mà ngày nay thường gọi là bộ đồng xử lý toán học (math coprocessor) hay bộ xử lý dấu phẩy động (floating-point unit - FPU). 8087 được gọi là đồng xử lý bởi lẽ nó không thể đơn thân hoạt động. Nó chỉ có thể làm việc khi đi cùng với 8086 và 8088, những bộ vi xử lý 16-bit đầu tiên của nhà Intel. Vào thời điểm bấy giờ, 8087 được xem là mạch tích hợp tinh xảo nhất từng được nhân loại chế tạo, nhưng dần dà thì các bộ đồng xử lý toán học cũng bị nuốt vào trong bản thân CPU.

Giới lập trình viên thời nay xài số dấu phẩy động hệt như thể chúng vốn dĩ đã là một phần của máy tính, mà đúng thật là như vậy.
Cảm ơn bạn đã đọc bài.
0

Thảo luận trên Bluesky

Đi

Đang tải bình luận...

Powered by Bluesky AT Protocol