Văn hay trong hiện tại, chữ tốt ở tương lai

Bạn chưa biết rõ về JavaScript đâu (You don't know JS yet) | Chương 1.4

Khám phá bản chất JavaScript từ lịch sử tên gọi, chuẩn ECMAScript và tính đa mô thức. Xóa bỏ lầm tưởng, xây nền tảng vững chắc cho nhà phát triển website.

37 phút đọc.

0 lượt xem.

Mở đầu

Tại thời điểm bạn đã hoàn thành việc tự tay viết những chương trình phần mềm đầu tiên, rất có thể bạn đang bắt đầu cảm thấy quen thuộc và thoải mái hơn với khái niệm khởi tạo các biến số cũng như lưu trữ các giá trị dữ liệu vào bên trong chúng. Quá trình làm việc, thao tác và quản lý các biến số được đánh giá là một trong những hoạt động mang tính chất nền tảng, sơ khai và thiết yếu nhất mà bất kỳ một kỹ sư phần mềm nào cũng bắt buộc phải thực hiện trong lĩnh vực lập trình điện toán. Mặc dù vậy, có một khả năng rất cao là bạn vẫn chưa bao giờ thực sự dành thời gian để suy xét một cách cặn kẽ và thấu đáo về các cơ chế vận hành ẩn sâu bên dưới hệ thống, những cơ chế phức tạp mà công cụ thông dịch sử dụng để tổ chức, sắp xếp và quản trị hệ thống biến số này. Vấn đề được đặt ra ở đây không thuần túy chỉ là cách thức mà bộ nhớ vật lý được cấp phát trên phần cứng của máy tính, mà sâu xa hơn là bài toán logic: làm thế nào ngôn ngữ JavaScript có thể nhận biết chính xác biến số nào được phép truy cập bởi một câu lệnh bất kỳ tại một thời điểm cụ thể, và hệ thống sẽ xử lý ra sao khi phải đối mặt với tình huống hai biến số hoàn toàn trùng lặp về mặt tên gọi. Lời giải đáp cho những câu hỏi mang tính bản chất này được thể hiện dưới hình thái của một bộ quy tắc được định nghĩa vô cùng chặt chẽ, được giới hàn lâm gọi là {Scope (Tạm dịch: Phạm vi.)}. Xuyên suốt nội dung của tài liệu này, chúng ta sẽ tiến hành đào sâu vào mọi khía cạnh cấu thành nên phạm vi, từ cách thức nó vận hành, những giá trị thực tiễn mà nó mang lại, những cạm bẫy nguy hiểm cần phải né tránh, cho đến việc chỉ ra những mẫu thiết kế phạm vi phổ biến đóng vai trò định hướng cho cấu trúc của toàn bộ chương trình. Bước đi đầu tiên mang tính chiến lược của chúng ta là vén bức màn bí mật, khám phá cách thức mà công cụ thông dịch ngôn ngữ xử lý chương trình của chúng ta từ rất lâu trước khi nó thực sự được đưa vào chạy. Hệ sinh thái này thường xuyên bị phân loại một cách vội vã là một ngôn ngữ kịch bản thuần thông dịch, dẫn đến việc phần lớn mọi người đều mang một định kiến sai lầm rằng các chương trình được xử lý theo một đường thẳng duy nhất từ trên xuống dưới. Thế nhưng, sự thật kiến trúc là ngôn ngữ này bắt buộc phải trải qua một giai đoạn phân tích và biên dịch hoàn toàn tách biệt trước khi bất kỳ một tiến trình thực thi nào được phép bắt đầu. Những quyết định mang tính chiến lược của tác giả viết mã về việc bố trí vị trí của các biến số, các hàm toán học và các khối mã lệnh có mối tương quan với nhau ra sao sẽ được hệ thống phân tích cẩn trọng dựa trên bộ quy tắc của phạm vi, ngay trong giai đoạn phân tích cú pháp và biên dịch khởi thủy này.

Bản chất biên dịch và thông dịch trong hệ sinh thái JavaScript

Kiến trúc xử lý mã nguồn của hệ sinh thái JavaScript là một sự giao thoa tinh tế giữa lý thuyết trình biên dịch kinh điển và những đòi hỏi khắt khe về mặt hiệu năng trong môi trường trình duyệt hiện đại. Việc phá vỡ định kiến về một ngôn ngữ thuần thông dịch là bước đi đầu tiên để các kỹ sư phần mềm có thể tiếp cận và làm chủ hệ thống quy tắc phân giải định danh phức tạp của ngôn ngữ này.

Sự khác biệt nền tảng giữa biên dịch và thông dịch

Bạn có thể đã từng nghe nhắc đến thuật ngữ biên dịch mã nguồn trong quá khứ, nhưng có lẽ đối với nhiều người, khái niệm này vẫn hiện lên như một chiếc hộp đen đầy bí ẩn, nơi mà mã nguồn thô được đẩy vào ở một đầu và những chương trình điện toán có khả năng thực thi kỳ diệu lại tự động chui ra ở đầu còn lại. Tuy nhiên, dưới góc nhìn của khoa học máy tính, quy trình này hoàn toàn không chứa đựng bất kỳ sự huyền bí hay phép thuật nào cả. Quá trình biên dịch mã nguồn thực chất là một chuỗi các bước xử lý kỹ thuật mang tính tuần tự, có nhiệm vụ tiếp nhận văn bản thô của đoạn mã bạn viết và chuyển hóa toàn bộ khối lượng văn bản đó thành một danh sách dài các chỉ thị cấp thấp mà bộ vi xử lý trung tâm của máy tính có thể đọc hiểu và thi hành. Điểm đặc trưng nhất của mô hình này là toàn bộ mã nguồn của chương trình sẽ được hệ thống tiêu hóa và biến đổi trong cùng một thời điểm duy nhất, và tập hợp các chỉ thị mã máy sinh ra từ quá trình đó sẽ được lưu trữ lại dưới dạng một kết quả đầu ra (thường là một tệp tin nhị phân độc lập) để có thể được triệu hồi và thực thi vào một thời điểm bất kỳ trong tương lai. Mô hình này mang lại một lợi thế khổng lồ về mặt tối ưu hóa hiệu năng, bởi vì công cụ biên dịch có đủ thời gian và không gian bộ nhớ để quan sát toàn bộ bức tranh kiến trúc của hệ thống phần mềm, từ đó đưa ra những quyết định sắp xếp lại luồng điều khiển và loại bỏ những đoạn mã dư thừa một cách cực kỳ triệt để trước khi người dùng cuối thực sự tương tác với ứng dụng.

Mặt khác, chắc hẳn bạn cũng đã từng được tiếp xúc với khái niệm rằng một đoạn mã có thể được thông dịch, vậy phương thức này có điểm gì khác biệt cốt lõi so với việc bị biên dịch? Xét về mục đích tối thượng, quá trình thông dịch cũng thực hiện một nhiệm vụ tương tự như quá trình biên dịch, đó là chuyển đổi chương trình phần mềm do con người viết ra thành các chỉ thị mà máy móc có thể thi hành. Thế nhưng, mô hình xử lý và luồng công việc của nó lại đi theo một triết lý hoàn toàn khác biệt. Trái ngược với việc một chương trình được biên dịch toàn bộ cùng một lúc, với mô hình thông dịch, văn bản mã nguồn sẽ được hệ thống biến đổi một cách chậm rãi, nhích từng dòng một; mỗi một dòng lệnh hoặc một biểu thức sẽ được dịch và ngay lập tức bị đưa vào thực thi trước khi hệ thống kịp tiến hành xử lý đến dòng lệnh tiếp theo của văn bản mã nguồn. Phương pháp tiếp cận tuyền tính này khiến cho công cụ thông dịch bị mắc kẹt trong một tầm nhìn hạn hẹp, nó chỉ biết đến sự tồn tại của câu lệnh hiện tại mà hoàn toàn mù tịt về những gì đang chờ đợi ở phía dưới đoạn mã. Chính vì sự thiếu vắng khả năng quan sát tổng thể này, các hệ thống thông dịch thuần túy thường xuyên phải gánh chịu những tổn thất nặng nề về tốc độ tính toán, đồng thời chúng cũng bất lực trong việc phát hiện ra những điểm mâu thuẫn logic về mặt phân bổ bộ nhớ hoặc những lỗi sai về mặt cấu trúc cú pháp ở những phân vùng mã chưa được chạm tới.

Một câu hỏi mang tính học thuật được đặt ra: liệu hai mô hình xử lý điện toán này có mang tính chất loại trừ lẫn nhau một cách tuyệt đối hay không? Nhìn chung, trong lịch sử phát triển của các hệ thống máy tính truyền thống, câu trả lời thường là có. Tuy nhiên, vấn đề này trong kỷ nguyên công nghệ hiện đại lại mang nhiều sắc thái phức tạp hơn rất nhiều, bởi vì quá trình thông dịch thực tế có thể tồn tại dưới nhiều hình thái tiến hóa khác nhau chứ không chỉ đơn thuần là việc vận hành thô sơ từng dòng một trên văn bản mã nguồn. Các công cụ thông dịch ngôn ngữ JavaScript thế hệ mới thực chất đang ứng dụng và kết hợp vô số các biến thể kỹ thuật tinh vi của cả hai mô hình biên dịch và thông dịch trong quá trình xử lý các chương trình phần mềm. Chúng ta cần phải nhận thức rõ ràng rằng, để có thể xây dựng nên một mô hình tư duy chuẩn xác về cách thức hệ thống quy tắc định danh hoạt động, ngôn ngữ JavaScript phải được phác họa một cách chính xác nhất dưới tư cách là một ngôn ngữ được biên dịch. Việc phân loại ngôn ngữ này vào nhóm ngôn ngữ được biên dịch hoàn toàn không quan tâm đến mô hình phân phối đối với các định dạng thực thi nhị phân của nó, mà thay vào đó, mục đích là nhằm duy trì một sự phân định rạch ròi, mạch lạc trong tâm trí của chúng ta về sự tồn tại của một giai đoạn nơi mã nguồn được hệ thống nhai nuốt, xử lý và phân tích cấu trúc; giai đoạn này theo quan sát thực tiễn là chắc chắn xảy ra và không thể chối cãi là nó phải diễn ra từ rất lâu trước khi đoạn mã đó bắt đầu chạy nghiệm thu trên bộ nhớ.

Ba giai đoạn cốt lõi của tiến trình biên dịch mã nguồn

Trong nền tảng lý thuyết trình biên dịch kinh điển, một chương trình phần mềm bất kỳ sẽ bị hệ thống biên dịch băm nhỏ và xử lý thông qua ba giai đoạn mang tính nền tảng, mà giai đoạn mở màn được gọi là quá trình mã hóa chuỗi hoặc phân tích từ vựng. Giai đoạn này chịu trách nhiệm đập vỡ một chuỗi văn bản dài các ký tự thành những mảnh ghép rời rạc mang ý nghĩa logic (đối với ngôn ngữ), những mảnh ghép này được gọi là các thẻ từ. Lấy ví dụ, hãy xem xét một đoạn chương trình vô cùng đơn giản: var a = 2;. Đoạn chương trình ngắn ngủi này gần như chắc chắn sẽ bị hệ thống công cụ băm nhỏ thành tập hợp các thẻ từ tuần tự như sau: var, a, =, 2, và dấu chấm phẩy ;. Khoảng trắng tồn tại trong mã nguồn có thể được bảo tồn lại như một thẻ từ độc lập hoặc bị hệ thống xóa sổ hoàn toàn, điều này phụ thuộc hoàn toàn vào việc khoảng trắng đó có mang ý nghĩa quyết định về mặt cấu trúc ngữ pháp hay không. Sự khác biệt giữa thuật ngữ mã hóa chuỗi và thuật ngữ phân tích từ vựng là vô cùng vi tế và thường chỉ được tranh luận trong môi trường học thuật, nhưng điểm mấu chốt của sự phân biệt này xoay quanh việc liệu các thẻ từ này được hệ thống nhận diện thông qua một phương thức phi trạng thái hay một phương thức có lưu giữ trạng thái. Nói một cách dễ hiểu nhất, nếu công cụ mã hóa chuỗi buộc phải gọi đến các bộ quy tắc phân tích có lưu giữ trạng thái bộ nhớ để tự mình suy luận xem ký tự a nên được coi là một thẻ từ biệt lập hay nó chỉ là một phần nhỏ cấu thành nên một thẻ từ phức tạp khác, thì toàn bộ chuỗi hành động đó sẽ được giới chuyên môn định nghĩa chính xác là quá trình phân tích từ vựng.

Bước qua giai đoạn khởi thủy, tiến trình biên dịch sẽ bước vào giai đoạn thứ hai mang tên gọi là phân tích cú pháp. Giai đoạn này có nhiệm vụ tiếp nhận một luồng dữ liệu (thường được cấu trúc dưới dạng một mảng) chứa đầy các thẻ từ đã được bóc tách từ bước trước, và tiến hành nhào nặn luồng dữ liệu thô đó thành một cấu trúc cây đồ sộ bao gồm vô số các phần tử lồng ghép vào nhau, cấu trúc cây này đóng vai trò đại diện tập thể cho toàn bộ cấu trúc ngữ pháp của chương trình phần mềm. Cấu trúc dữ liệu hình cây cực kỳ phức tạp này được giới khoa học máy tính gọi bằng thuật ngữ chuyên ngành là {Abstract Syntax Tree (Tạm dịch: Cây cú pháp trừu tượng.)}. Ví dụ như, để biểu diễn cấu trúc cây cho đoạn lệnh var a = 2;, hệ thống có thể sẽ khởi tạo một nút gốc ở tầng cao nhất mang tên là khai báo biến số, bên dưới nút gốc này là một nút con mang tên là định danh (nút này chứa giá trị là ký tự a), và một nút con khác mang tên là biểu thức gán, bản thân nút biểu thức gán này lại tiếp tục ôm trọn một nút con của riêng nó mang tên là giá trị số nguyên bản (với giá trị được lưu trữ bên trong là 2). Việc xây dựng thành công cấu trúc cây này là minh chứng cho thấy mã nguồn hoàn toàn tuân thủ các quy tắc ngữ pháp của ngôn ngữ, và nó tạo ra một sơ đồ tư duy hoàn hảo để công cụ biên dịch có thể lướt qua và tối ưu hóa ở các bước tiếp theo.

Giai đoạn cuối cùng khép lại chu trình xử lý là giai đoạn kiến tạo mã máy. Giai đoạn này tiếp nhận cấu trúc cây cú pháp trừu tượng đã được hoàn thiện và thực hiện phép màu biến nó thành những đoạn mã có năng lực thực thi trực tiếp trên hệ thống vật lý. Quá trình này biến thiên cực kỳ mạnh mẽ tùy thuộc vào bản chất của ngôn ngữ lập trình, hệ sinh thái nền tảng mà nó nhắm đến làm mục tiêu hoạt động, cùng với hàng loạt các yếu tố kỹ thuật phức tạp khác. Đối với ngôn ngữ JavaScript, công cụ thông dịch sẽ cầm lấy cấu trúc cây cú pháp trừu tượng vừa được mô tả cho đoạn lệnh var a = 2; và biến đổi nó thành một tổ hợp các chỉ thị ngôn ngữ máy nhằm mục đích thực sự tạo ra một biến số mang tên là a (quá trình này bao gồm các thao tác cấp thấp như yêu cầu hệ điều hành dành riêng một khoảng dung lượng bộ nhớ, thiết lập con trỏ, v.v.), và sau khi hoàn tất, hệ thống mới tiến hành lưu trữ một giá trị cụ thể vào bên trong vùng nhớ của biến a đó. Tuy nhiên, công cụ thông dịch của ngôn ngữ này trong thực tế lại sở hữu độ phức tạp khủng khiếp hơn rất nhiều so với chỉ ba giai đoạn cơ bản vừa nêu. Trong suốt quá trình phân tích cú pháp và kiến tạo mã máy, tồn tại vô số các bước trung gian được thiết kế để tối ưu hóa hiệu năng của tiến trình thực thi, ví dụ như hành động triệt tiêu các phần tử tính toán dư thừa. Các công cụ này hoàn toàn không có được sự xa xỉ về mặt thời gian dư dả để nhẩn nha thực hiện các công việc xử lý và tối ưu hóa của mình, bởi vì quá trình biên dịch của nó không hề diễn ra trong một bước xây dựng được chuẩn bị từ trước một cách nhàn hạ như các ngôn ngữ lập trình hệ thống khác. Thay vào đó, toàn bộ quy trình đồ sộ này thường xuyên bị ép buộc phải hoàn tất chỉ trong vỏn vẹn vài micrô giây ngắn ngủi ngay sát trước thời điểm đoạn mã bị ném vào thực thi.

Bằng chứng thực tiễn về cơ chế phân tích hai giai đoạn

Để có thể phát biểu một cách giản lược và dễ nắm bắt nhất có thể, sự quan sát mang tính chất trọng yếu nhất mà chúng ta có thể rút ra về quá trình xử lý của các chương trình JavaScript là toàn bộ tiến trình này bắt buộc phải diễn ra qua (ít nhất) hai giai đoạn hoàn toàn tách biệt: giai đoạn phân tích cú pháp và biên dịch đi tiên phong, sau đó mới nhường sân khấu cho giai đoạn thực thi nối gót theo sau. Việc rạch ròi tách biệt một giai đoạn phân tích cú pháp và biên dịch ra khỏi giai đoạn thực thi diễn ra sau đó không phải là một giả thuyết khoa học hay một quan điểm chủ quan, mà nó là một thực tại kiến trúc có thể quan sát và chứng minh bằng thực nghiệm. Mặc dù tài liệu đặc tả chính thức của hệ sinh thái ngôn ngữ này không hề chứa đựng bất kỳ một quy định nào sử dụng từ ngữ bắt buộc phải biên dịch một cách trắng trợn, nhưng nó lại bắt buộc hệ thống phải thể hiện những hành vi mà về mặt bản chất kỹ thuật, chỉ có thể khả thi và thực tế nếu áp dụng triết lý tiếp cận biên dịch trước rồi mới thực thi sau. Tồn tại ba đặc tính vận hành nổi bật của chương trình mà bạn hoàn toàn có thể tự mình tiến hành quan sát để chứng minh chân lý này: lỗi cú pháp, lỗi cảnh báo sớm, và hiện tượng kéo lên.

Minh chứng đầu tiên đến từ cách hệ thống phản ứng với các lỗi cú pháp ngay từ thuở sơ khai của luồng thực thi. Hãy cùng phân tích một đoạn chương trình mà ở dòng đầu tiên, chúng ta gán chuỗi Xin chào cho biến số lời chào, dòng thứ hai tiến hành in biến số đó ra màn hình điều khiển, và ở dòng thứ ba, chúng ta cố ý tạo ra một lỗi cú pháp bằng cách viết sai một biểu thức với dấu chấm đặt sai vị trí ngay trước chuỗi Chào. Đoạn chương trình này khi chạy sẽ hoàn toàn không sản sinh ra bất kỳ một kết quả đầu ra nào (chuỗi Xin chào tuyệt đối không được in ra màn hình), mà thay vào đó, nó lập tức ném thẳng vào mặt lập trình viên một lỗi cú pháp, thông báo về sự hiện diện của một thẻ từ dấu chấm không mong muốn nằm chễm chệ ngay trước chuỗi Chào. Bởi vì cái lỗi cú pháp vật lý này được đặt ở vị trí sau câu lệnh in màn hình vốn dĩ được viết hoàn toàn đúng chuẩn mực ngữ pháp, nên nếu như ngôn ngữ JavaScript thực sự đang vận hành theo cơ chế thông dịch dịch tuần tự từ trên xuống dưới theo từng dòng một, thì bất kỳ ai cũng sẽ có quyền kỳ vọng một cách logic rằng thông điệp Xin chào bắt buộc phải được in ra màn hình thành công trước khi cái lỗi cú pháp ở dòng thứ ba kia kịp nổ tung và dừng chương trình. Nhưng sự việc đó đã không hề xảy ra. Trên thực tế, phương thức duy nhất để công cụ thông dịch có thể biết trước được sự tồn tại của một lỗi cú pháp nằm ẩn nấp ở tận dòng thứ ba, ngay cả trước khi nó bắt tay vào thi hành dòng lệnh thứ nhất và thứ hai, là việc công cụ này bắt buộc phải quét qua và phân tích cú pháp toàn bộ bức tranh của chương trình trước khi cho phép bất kỳ một phần tử nào được nổ máy chạy.

Minh chứng thứ hai phơi bày sức mạnh của quá trình biên dịch trước nằm ở cơ chế xử lý các lỗi cảnh báo sớm. Cùng xem xét một tình huống khởi đầu bằng lệnh in ra màn hình chuỗi Chào bạn, nối tiếp là một lời gọi hàm truyền vào hai đối số, nhưng bản thân phần định nghĩa của cái hàm đó lại cố tình khai báo hai tham số nhận vào trùng lặp tên gọi với nhau, đồng thời đặt một chỉ thị sử dụng chế độ nghiêm ngặt bên trong thân hàm. Kết quả là chuỗi Chào bạn hoàn toàn không được in ra, bất chấp việc nó là một câu lệnh tuân thủ ngữ pháp hoàn hảo. Thay vì thực thi, một lỗi cú pháp lại tiếp tục bị hệ thống ném ra ngay lập tức trước khi chương trình kịp khởi động. Trong tình huống cụ thể này, nguyên nhân gốc rễ là do chế độ nghiêm ngặt (chỉ được kích hoạt cục bộ cho riêng cái hàm đó) đã cấm tiệt hành vi định nghĩa các tham số trùng tên, một hành vi mà trong lịch sử vẫn luôn được nhắm mắt làm ngơ ở chế độ thông thường lỏng lẻo. Cái lỗi bị ném ra này hoàn toàn không phải là một lỗi cú pháp theo kiểu một chuỗi các thẻ từ bị biến dạng và sắp xếp lộn xộn, mà trong ngữ cảnh của chế độ nghiêm ngặt, tài liệu đặc tả bắt buộc nó phải được hệ thống phát hiện và ném ra dưới tư cách là một lỗi cảnh báo sớm từ rất lâu trước khi bất kỳ một nhịp đập thực thi nào được bắt đầu. Nhưng làm phương thức ma thuật nào mà công cụ thông dịch lại có thể biết trước được rằng cái tham số kia đã bị nhân bản trùng lặp? Bằng cách nào mà nó lại có thể thấu thị được việc cái hàm đó đang bị đặt dưới sự kiểm soát của chế độ nghiêm ngặt ngay trong lúc nó đang mải mê phân tích danh sách tham số (trong khi cái chỉ thị sử dụng chế độ nghiêm ngặt kia lại nằm sâu tít bên trong thân hàm và phải đọc sau đó mới thấy)? Một lần nữa, lời giải thích duy nhất hợp lý về mặt khoa học máy tính là toàn bộ cấu trúc mã nguồn bắt buộc phải trải qua một quá trình phân tích cú pháp trọn vẹn và toàn diện nhất trước khi bất kỳ một tiến trình thực thi nào có cơ hội được diễn ra. Bằng chứng cuối cùng và cũng là hiện tượng phổ biến nhất – hiện tượng kéo lên – xảy ra khi một biến số bị truy cập trước khi nó được hệ thống phân giải khai báo, việc công cụ thông dịch biết chắc chắn rằng sẽ có một khai báo chặn đầu ở các dòng lệnh phía dưới chỉ có thể khả thi nếu nó đã thực hiện một vòng lặp vẽ bản đồ toàn bộ không gian phạm vi từ trước đó.

Phân tích vai trò của biến số và cơ chế can thiệp phạm vi

Bên cạnh việc thiết lập cấu trúc chương trình, công cụ biên dịch còn phải thực hiện một nhiệm vụ cực kỳ quan trọng là dán nhãn phân loại cho mọi sự xuất hiện của các biến số bên trong mã nguồn. Việc xác định rõ vai trò của từng biến số không chỉ là yêu cầu bắt buộc của bộ phân tích cú pháp, mà nó còn là nền tảng để hệ thống xử lý các nỗ lực truy cập dữ liệu và định hình nên hệ thống quy tắc của phạm vi từ vựng.

Phân loại vai trò của biến số theo mục tiêu và nguồn phát

Với việc đã xây dựng được một sự nhận thức sắc bén về mô hình xử lý hai giai đoạn của một chương trình JavaScript (biên dịch trước, sau đó mới thực thi), chúng ta hãy cùng nhau chuyển hướng sự chú ý sang cách thức mà công cụ thông dịch tiến hành nhận diện các biến số và thiết lập nên các vùng không gian phạm vi của một chương trình trong lúc nó đang được biên dịch. Ngoại trừ các câu lệnh khai báo thuần túy, mọi sự xuất hiện của các biến số hay các định danh bên trong một chương trình phần mềm đều được hệ thống phân công đảm nhiệm một trong hai vai trò cốt lõi: hoặc chúng sẽ đóng vai trò là một đích đến của một thao tác gán giá trị, hoặc chúng sẽ sắm vai là một nguồn phát cung cấp một giá trị dữ liệu nào đó. Khi đào sâu vào lý thuyết trình biên dịch, các thuật ngữ hàn lâm thường dùng để gọi tên hai vai trò này lần lượt là {Left-Hand Side (Tạm dịch: Phía bên trái.)} và {Right-Hand Side (Tạm dịch: Phía bên phải.)}. Mặc dù tên gọi gợi ý về vị trí trái phải so với một toán tử gán bằng =, nhưng trên thực tế, các đích đến của phép gán và các nguồn phát giá trị không phải lúc nào cũng ngoan ngoãn hiện diện một cách rành rành ở phía bên trái hay phía bên phải của một dấu =, do đó, việc tư duy và hình dung thông qua các khái niệm đích đếnnguồn phát sẽ mang lại một góc nhìn minh bạch và ít gây nhầm lẫn hơn rất nhiều. Làm thế nào để trí tuệ con người có thể tự mình xác định xem liệu một biến số có đang đóng vai trò là một đích đến hay không? Hãy đặt ra một phép thử đơn giản: kiểm tra xem liệu có bất kỳ một giá trị dữ liệu nào đang trên đường bay tới và chuẩn bị được gán thẳng vào bên trong cái biến số đó hay không; nếu câu trả lời là có, thì biến số đó đích thị là một đích đến. Nếu giả thuyết đó sai, thì theo phương pháp loại trừ, biến số đó nghiễm nhiên phải là một nguồn phát giá trị.

Để công cụ thông dịch của ngôn ngữ có thể xử lý các biến số của một chương trình phần mềm một cách chuẩn xác không tì vết, nhiệm vụ tối thượng đầu tiên của nó là phải tiến hành dán nhãn phân loại cho mọi sự hiện diện của một biến số xem nó thuộc phe đích đến hay phe nguồn phát. Lấy ví dụ với câu lệnh gán mảng cho biến sinh viên, đây rõ ràng và không thể chối cãi là một thao tác gán giá trị; hãy luôn khắc cốt ghi tâm rằng, bộ phận khai báo của câu lệnh đã được hệ thống bóc tách và giải quyết trọn vẹn dưới tư cách là một hành động khai báo ngay trong giai đoạn biên dịch, và do đó nó hoàn toàn trở nên vô hình và không còn bất kỳ giá trị nào trong giai đoạn thực thi nữa. Thế nhưng, luôn ẩn nấp những thao tác gán vào đích đến khác tinh vi hơn bên trong cấu trúc mã mà nếu chỉ lướt qua bạn sẽ rất khó lòng phát hiện. Một ví dụ điển hình là cấu trúc vòng lặp for (let sinh_vien of danh_sach_sinh_vien); câu lệnh này âm thầm thực thi hành vi gán một giá trị mới toanh vào cho biến số sinh_vien ở mỗi một chu kỳ lặp. Một tham chiếu đích đến khác lại ẩn mình dưới dạng một lời gọi hàm: khi bạn truyền một giá trị số vào làm đối số cho một hàm, thực chất hệ thống đã tự động kích hoạt một thao tác gán cái giá trị số đó vào cho cái tham số định danh đã được định nghĩa trong hàm.

Và vẫn còn tồn tại một tham chiếu đích đến cuối cùng mang tính chất vô cùng tinh tế và thường xuyên đánh lừa các kỹ sư non trẻ: bản thân câu lệnh khai báo một hàm điện toán thực chất cũng là một trường hợp đặc thù của một tham chiếu đích đến. Bạn có thể mường tượng nó hoạt động gần giống như một câu lệnh gán một biểu thức hàm ẩn danh vào một biến số, nhưng sự so sánh đó không phản ánh được tính chính xác tuyệt đối về mặt kỹ thuật. Một định danh mang tên của hàm sẽ được hệ thống khởi tạo (ngay tại thời điểm biên dịch), nhưng phần gán giá trị thuật toán cho cái hàm đó cũng được hệ thống ưu tiên xử lý luôn tại giai đoạn biên dịch; mối lương duyên liên kết giữa cái định danh tên hàm và giá trị thực thể của cái hàm đó sẽ được hệ thống tự động thiết lập một cách kiên cố ngay tại điểm khởi đầu của không gian phạm vi, thay vì phải nằm chờ đợi một cách thụ động cho đến khi dòng thời gian thực thi trôi qua một câu lệnh chứa toán tử gán = vật lý. Sự liên kết tự động và ma thuật giữa một hàm và biến số định danh của nó được giới chuyên môn gọi bằng thuật ngữ kéo lên của hàm. Một khi chúng ta đã sàng lọc và khoanh vùng thành công toàn bộ các tham chiếu đích đến bên trong chương trình, thì toàn bộ tập hợp các tham chiếu biến số còn sót lại bắt buộc phải khoác lên mình vai trò của các tham chiếu nguồn phát (bởi vì đơn giản là vĩnh viễn không còn một sự lựa chọn thứ ba nào khác!). Ý nghĩa thực tiễn to lớn lao nhất của việc thấu hiểu tường tận sự khác biệt giữa các đích đến và các nguồn phát nằm ở việc vai trò của một biến số sẽ chi phối trực tiếp đến thuật toán tra cứu của hệ thống, và quan trọng nhất là cách hệ thống xử lý khủng hoảng khi quá trình tra cứu biến số đó thất bại thảm hại.

Hiểm họa từ việc can thiệp phạm vi trong thời gian thực

Thông qua những phân tích kiến trúc ở các phần trước, một chân lý kỹ thuật lẽ ra phải trở nên sáng tỏ như ban ngày đối với mọi kỹ sư: ranh giới của không gian phạm vi phải được hệ thống chốt hạ, định hình một cách sắt đá ngay trong giai đoạn chương trình đang được biên dịch, và theo nguyên tắc thiết kế tối thượng, nó tuyệt đối không bao giờ được phép bị xê dịch hay chịu ảnh hưởng bởi bất kỳ điều kiện hay biến động nào xảy ra trong suốt thời gian hệ thống đang chạy thực thi. Mặc dù vậy, khi ngôn ngữ đang hoạt động dưới chế độ thông thường lỏng lẻo (chưa bật chế độ nghiêm ngặt), về mặt kỹ thuật vẫn còn chừa lại hai lỗ hổng cho phép lập trình viên ăn gian để phá vỡ quy luật bất di bất dịch này, cho phép họ thực hiện các hành vi sửa đổi cấu trúc của các phạm vi chương trình ngay trong lúc chương trình đó đang chạy. Cần phải gióng lên một hồi chuông cảnh báo khẩn cấp: tuyệt đối không có bất kỳ một kỹ thuật nào trong số hai kỹ thuật này nên được đem ra sử dụng trong môi trường sản xuất—chúng đều là những con dao hai lưỡi cực kỳ nguy hiểm, gây ra sự hỗn loạn tột độ về mặt logic, và là một kỹ sư chân chính, bạn nên tuân thủ việc bật chế độ nghiêm ngặt (nơi mà những lỗ hổng này bị niêm phong vĩnh viễn). Tuy nhiên, việc trang bị nhận thức về sự tồn tại của chúng là một kỹ năng phòng vệ thiết yếu, phòng trường hợp bạn vô tình dẫm phải chúng khi phải bảo trì những đống mã nguồn kế thừa cũ kỹ.

Lỗ hổng đầu tiên mang tên hàm eval(), một cỗ máy tiếp nhận một chuỗi văn bản chứa mã nguồn thô và ngang nhiên tiến hành biên dịch rồi ném nó vào thực thi một cách chớp nhoáng ngay giữa lúc chương trình chính đang vận hành. Nếu cái chuỗi văn bản mã nguồn đó chứa đựng bất kỳ một khai báo biến hay khai báo hàm nào, những khai báo đột ngột đó sẽ phá vỡ không gian, can thiệp vật lý và sửa đổi trực tiếp cái phạm vi hiện tại mà hàm eval() đó đang trú ngụ. Nếu không có sự hiện diện phá hoại của eval(), một biến số mồ côi bị gọi trong lệnh in ra màn hình chắc chắn sẽ không thể tồn tại trong từ điển hệ thống, và sẽ lập tức kích nổ một lỗi tham chiếu chí mạng. Thế nhưng eval() đã nhúng tay vào và thay đổi cấu trúc ADN của không gian phạm vi thuộc về hàm chứa nó ngay tại thời gian chạy. Hành vi xâm phạm này bị coi là một tội ác kiến trúc vì vô số những lý do tồi tệ, trong đó tổn thất nặng nề nhất là sự phá hủy hoàn toàn hiệu năng của hệ thống; công cụ biên dịch đã tốn công sức để tối ưu hóa cái không gian phạm vi đó từ trước, nay lại phải đập đi xây lại mỗi khi cái hàm chứa eval() bị gọi chạy. Do công cụ thông dịch không thể lường trước được chuỗi văn bản kia sẽ chứa đựng những ma thuật sửa đổi gì, nó buộc phải đưa ra một quyết định tồi tệ nhất: từ bỏ mọi nỗ lực tối ưu hóa hiệu suất truy cập biến cho toàn bộ phạm vi đó, khiến chương trình trở nên chậm chạp một cách thảm hại.

Thủ thuật ăn gian thứ hai lợi dụng từ khóa with, một công cụ ma quỷ có khả năng biến đổi linh hoạt một thực thể đối tượng thông thường thành một không gian phạm vi cục bộ hoàn toàn mới—nơi mà toàn bộ các thuộc tính dữ liệu của đối tượng đó bị ép buộc phải hóa thân thành các biến số định danh hoạt động bên trong khối không gian phạm vi mới vừa được tạo ra đó. Phạm vi toàn cục tuy không bị sửa đổi trực tiếp, nhưng cái đối tượng truyền vào đã bị hệ thống phù phép biến thành một không gian phạm vi ngay tại thời gian chạy chứ không phải là trong thời gian biên dịch, và một thuộc tính của nó nghiễm nhiên được phong tước để trở thành một biến số hoạt động bên trong phạm vi đó. Một lần nữa, đây lại là một ý tưởng tồi tệ đến cùng cực, gây ra những hệ lụy không thể cứu vãn về cả mặt tối ưu hiệu năng tốc độ lẫn khả năng đọc hiểu và suy luận luồng logic của con người. Sự khó lường của with khiến cho việc xác định nguồn gốc của một định danh trở thành một trò chơi đoán mò đầy rủi ro, và nó cực kỳ dễ dẫn đến việc vô tình tạo ra các biến số toàn cục rác gây ô nhiễm không gian tên chung. Bằng mọi giá, bạn phải tránh xa hàm eval() (ít nhất là trong các trường hợp nó sinh ra các khai báo mới) và tuyệt đối tẩy chay từ khóa with. Một lần nữa xin nhắc lại, cả hai con đường tà đạo này đều đã bị phong ấn hoàn toàn khi bạn đặt bước chân vào chế độ nghiêm ngặt, vì vậy nếu bạn duy trì thói quen viết mã trong chế độ nghiêm ngặt (và bạn thực sự phải làm như vậy!), thì mọi sự cám dỗ chết người này sẽ tự động tan biến vào hư vô.

Nguyên lý hoạt động của phạm vi từ vựng và sự phân giải

Thông qua hàng loạt các luận điểm và minh chứng thực tiễn, chúng ta đã khẳng định được một cách đanh thép rằng phạm vi của ngôn ngữ JavaScript được quyết định và đúc khuôn ngay tại thời điểm hệ thống đang tiến hành biên dịch mã nguồn; thuật ngữ hàn lâm dùng để chỉ định cho loại kiến trúc phạm vi này là phạm vi từ vựng. Chữ từ vựng trong thuật ngữ này có mối quan hệ huyết thống trực tiếp với giai đoạn phân tích từ vựng của tiến trình biên dịch, một giai đoạn nền tảng mà chúng ta đã dành thời gian mổ xẻ ở phần đầu của chương. Để có thể gạn đục khơi trong và cô đọng lại chương tài liệu khổng lồ này thành một kết luận mang tính ứng dụng cao nhất, tư tưởng cốt lõi của phạm vi từ vựng chính là việc nó bị chi phối và kiểm soát một cách độc tài, toàn diện bởi vị trí vật lý mà bạn quyết định đặt các hàm điện toán, các khối mã lệnh, và các câu lệnh khai báo biến số, dựa trên mối quan hệ không gian lồng ghép giữa chúng với nhau. Bạn với tư cách là tác giả của mã nguồn, chính là kiến trúc sư vĩ đại nhất quyết định hình thái vĩnh viễn của cấu trúc phạm vi.

Nếu bạn đặt bút viết một câu lệnh khai báo biến nằm lọt thỏm bên trong bụng của một hàm, công cụ biên dịch sẽ trực tiếp xử lý và ghi nhận câu lệnh khai báo này trong lúc nó đang mải mê phân tích cấu trúc của cái hàm đó, và nó sẽ tạo ra một mối liên kết vĩnh cửu, cột chặt cái khai báo biến đó vào không gian phạm vi của riêng hàm đó. Nếu một biến số được sinh ra bởi các từ khóa khai báo tuân thủ phạm vi khối, thì thay vì bị liên kết với cái hàm bao bọc bên ngoài, biến số đó sẽ ngay lập tức bị khóa chặt và liên kết vòng đời với cái khối mã lệnh gần nhất đang bao bọc lấy nó. Đi xa hơn nữa, một thao tác tham chiếu đến một biến số (bất kể biến số đó đang phải gánh vác vai trò là một đích đến hay một nguồn phát) bắt buộc phải được hệ thống phân giải thành công bằng cách tìm thấy nguồn gốc của nó xuất phát từ một trong những vùng không gian phạm vi đang có sẵn về mặt từ vựng đối với nó; nếu hệ thống lùng sục mà vẫn không tìm thấy, biến số đó sẽ bị kết án là chưa được khai báo (và bản án này thường sẽ lập tức thi hành bằng cách ném thẳng một lỗi chí mạng vào mặt chương trình!). Trong trường hợp hệ thống quét qua không gian phạm vi hiện tại mà không tìm thấy bóng dáng của biến số được khai báo, nó sẽ bắt đầu cuộc hành trình leo ngược lên phía trên, gõ cửa và thỉnh thị vùng không gian phạm vi nằm ngay bên ngoài đang bao bọc lấy nó. Chu trình trèo ra ngoài từng lớp vỏ bọc không gian phạm vi lồng ghép này sẽ kiên nhẫn tiếp diễn không ngừng nghỉ cho đến khi nó vấp phải một khai báo biến mang tên gọi trùng khớp, hoặc cho đến khi nó chạm tay vào trần nhà của không gian phạm vi toàn cục và tuyệt vọng nhận ra rằng không còn bất kỳ nơi nào cao hơn để đi nữa.

Có một điểm mấu chốt kiến trúc cực kỳ quan trọng cần phải được làm rõ để tránh những ảo tưởng tai hại: tiến trình biên dịch mã nguồn trên thực tế hoàn toàn không thực hiện bất kỳ một hành động vật lý nào liên quan đến việc đào bới, cấp phát hay bảo lưu tài nguyên bộ nhớ trên phần cứng để nhường chỗ cho các không gian phạm vi và các biến số. Tại thời điểm này, chưa hề có một phần tỷ nào của chương trình được hệ thống cấp phép cho chạy thực thi. Thay vào đó, trí tuệ của tiến trình biên dịch nằm ở việc nó đã vẽ ra một tấm bản đồ tư duy chi tiết đến từng chân tơ kẽ tóc, phác thảo toàn bộ mọi không gian phạm vi từ vựng, lên kế hoạch chính xác xem chương trình sẽ cần phải yêu cầu những nguồn tài nguyên nào trong suốt quá trình nó chạy thực thi. Bạn có thể mường tượng tấm bản đồ chiến lược này như một đoạn mã ẩn được cấy ghép vào chương trình để hệ thống sử dụng như một cẩm nang tại thời gian chạy, tấm bản đồ này định nghĩa ranh giới của mọi không gian phạm vi và làm thủ tục đăng ký nhân khẩu cho toàn bộ mọi định danh (biến số) thuộc về từng không gian phạm vi đó. Nói một cách tóm gọn, mặc dù danh tính và cấu trúc của các phạm vi đã được hệ thống định vị và chốt sổ từ giai đoạn biên dịch, nhưng chúng chỉ thực sự hóa thành các thực thể vật lý tồn tại chiếm chỗ trong bộ nhớ khi chương trình bước vào thời gian chạy, cụ thể là mỗi khi một không gian phạm vi được hệ thống gọi tên và yêu cầu phải khởi động.

Kết luận

Việc từ bỏ định kiến về một ngôn ngữ thông dịch thô sơ để đón nhận sự thật kiến trúc phức tạp về mô hình biên dịch trước thực thi là một bước nhảy vọt trong tư duy của mọi kỹ sư lập trình JavaScript. Thông qua việc phân tích cách thức hệ thống phản ứng với các lỗi cảnh báo sớm và cách nó bóc tách từ vựng, chúng ta đã chứng minh được sự tồn tại của một công cụ phân tích tĩnh vô hình nhưng sở hữu quyền lực tuyệt đối trong việc kiến tạo nên toàn bộ cấu trúc không gian của chương trình. Việc nắm vững cơ chế phân giải định danh theo chuỗi phạm vi từ vựng, đồng thời phân định rạch ròi vai trò của từng biến số không chỉ giúp ngăn chặn những lỗi tham chiếu nguy hiểm, mà còn là kim chỉ nam để các kiến trúc sư phần mềm thiết kế nên những hệ thống phân cấp dữ liệu kín kẽ, an toàn và tối ưu hóa tối đa hiệu năng của công cụ thông dịch trong môi trường thực tiễn.

Chuyên mục ydkjs

Theo dõi hành trình

Hãy để lại thông tin, khi có gì mới thì Nhà văn sẽ gửi thư đến bạn để cập nhật. Cam kết không gửi email rác.

Họ và tên

Email liên lạc

Đôi dòng chia sẻ