Mở đầu
Đến thời điểm hiện tại, toàn bộ nguồn lực tập trung của chúng ta đã được dồn vào việc giải phẫu các cơ chế vận hành của không gian phạm vi và biến số ở mức độ vi mô. Với nền móng kiến trúc vững chãi đó đã được thiết lập, giờ là lúc chúng ta phải nâng tầm tư duy lên một hệ quy chiếu vĩ mô hơn: các quyết định mang tính chiến lược và các khuôn mẫu thiết kế mà chúng ta sẽ áp dụng xuyên suốt toàn bộ vòng đời của một chương trình phần mềm. Để bắt đầu cuộc hành trình mới này, chúng ta sẽ cùng nhau mổ xẻ cách thức và lý do tại sao chúng ta bắt buộc phải sử dụng các cấp độ lồng ghép không gian phạm vi khác nhau (từ các hàm điện toán cho đến các khối mã lệnh) như những công cụ đắc lực để tổ chức, sắp xếp lại hệ thống biến số của chương trình, với một mục tiêu tối thượng: giảm thiểu đến mức tối đa thảm họa phơi bày phạm vi quá mức cần thiết.
Nguyên lý phơi bày tối thiểu
Việc các hàm điện toán được quyền tự do vạch ra những không gian phạm vi độc lập của riêng chúng là một điều hoàn toàn dễ hiểu và hợp logic. Thế nhưng, tại sao chúng ta lại phải cần đến các khối mã lệnh để kiến tạo ra thêm các không gian phạm vi phụ trợ làm gì?
Ngành công nghiệp kỹ thuật phần mềm từ lâu đã đúc kết và đưa ra một nguyên lý kỷ luật nền tảng, thường được áp dụng một cách khắt khe trong lĩnh vực bảo mật phần mềm, mang tên gọi Nguyên lý Đặc quyền Tối thiểu (The Principle of Least Privilege - POLP). Và một biến thể phái sinh của nguyên lý này, cực kỳ phù hợp để áp dụng vào cuộc thảo luận hiện tại của chúng ta về không gian phạm vi, thường được giới chuyên môn gọi tên là Nguyên lý Phơi bày Tối thiểu (POLE). POLP thể hiện một tư thế phòng ngự chủ động trong kiến trúc phần mềm: mọi thành tố cấu thành nên hệ thống phải được thiết kế và chế tạo để vận hành với mức độ đặc quyền tối thiểu nhất, quyền hạn truy cập hẹp nhất, và sự phơi bày ra bên ngoài thấp nhất có thể. Nếu mỗi một mắt xích riêng lẻ chỉ được cấp phát chính xác những quyền năng tối thiểu vừa đủ để hoàn thành nhiệm vụ của nó, thì toàn bộ hệ thống tổng thể sẽ trở nên vững như bàn thạch dưới góc độ bảo mật, bởi vì bất kỳ một sự thỏa hiệp hay sụp đổ của một mắt xích nào cũng sẽ bị giới hạn và giảm thiểu tối đa tác động tàn phá lây lan sang các phần còn lại của hệ thống. Nếu như POLP tập trung mũi nhọn vào việc thiết kế kiến trúc ở cấp độ các thành phần hệ thống lớn, thì biến thể POLE Phơi bày lại chĩa mũi giáo vào một cấp độ vi mô hơn rất nhiều; chúng ta sẽ áp dụng triết lý này vào cách thức mà các không gian phạm vi lồng ghép và tương tác với nhau.
Khi tuân thủ nghiêm ngặt POLE, thứ mà chúng ta khao khát muốn giảm thiểu sự phơi bày ở đây là gì? Câu trả lời vô cùng đơn giản: chính là các biến số đã được hệ thống đăng ký hộ khẩu bên trong mỗi không gian phạm vi. Hãy thử tư duy theo hướng ngược lại: tại sao bạn lại không vứt toẹt toàn bộ mớ biến số của chương trình ra ngoài một cái sân chơi chung khổng lồ là không gian phạm vi toàn cục? Ý tưởng đó chắc hẳn sẽ ngay lập tức khiến bạn rùng mình và cảm thấy đó là một tối kiến tồi tệ, nhưng việc phân tích lý do sâu xa đằng sau cái cảm giác tồi tệ đó lại vô cùng đáng giá. Khi các biến số vốn dĩ chỉ được sinh ra để phục vụ cho một phân vùng nhỏ của chương trình lại bị phơi bày một cách trần trụi cho một phân vùng khác nhòm ngó tới, thông qua các kênh không gian phạm vi chung chạ, có ba thảm họa khốc liệt thường xuyên trực chờ để giáng xuống:
– Xung đột định danh: nếu bạn sử dụng một cái tên định danh chung chung, phổ biến cho một biến số/hàm ở hai phân vùng hoàn toàn xa lạ của chương trình, nhưng trớ trêu thay cái tên định danh đó lại có nguồn gốc từ một cái xô không gian phạm vi dùng chung (như không gian toàn cục), thì thảm họa xung đột tên gọi chắc chắn sẽ nổ ra, và những con rệp máy (bugs) sẽ nhanh chóng sinh sôi nảy nở khi một phân vùng vô tình thao tác với cái biến số/hàm đó theo một kịch bản mà phân vùng kia vĩnh viễn không thể lường trước được. Hãy thử tưởng tượng một viễn cảnh tồi tệ nơi tất cả các vòng lặp trong hệ thống của bạn đều dùng chung một cái biến đếm toàn cục i, và rồi định mệnh sắp đặt để một vòng lặp ở hàm này đang chạy dở dang lại vô tình bị một vòng lặp từ hàm khác xen ngang, và thế là cái biến i dùng chung đó đột ngột bị tiêm vào một giá trị rác hoàn toàn bất ngờ.
– Hành vi dị thường: nếu bạn phơi bày những biến số/hàm mà lẽ ra công năng sử dụng của chúng chỉ mang tính chất nội bộ riêng tư cho một mảnh ghép của chương trình, bạn đang vô tình trao chìa khóa cho các kỹ sư khác để họ tự do sử dụng chúng theo những cách thức lệch lạc mà bạn chưa từng thiết kế, điều này sẽ phá vỡ các hành vi được lập trình sẵn và là mầm mống sinh ra lỗi. Ví dụ, nếu phân vùng chương trình của bạn đặt cược toàn bộ logic vào việc một mảng dữ liệu chỉ được phép chứa các con số, nhưng một đoạn mã của ai đó lại ngang nhiên thọc tay vào và nhồi nhét thêm các giá trị boolean hay chuỗi ký tự vào mảng đó, thì đoạn mã của bạn gần như chắc chắn sẽ phát điên và hành xử một cách vô cùng kỳ quái. Tồi tệ hơn nữa, việc hớ hênh phơi bày các chi tiết riêng tư còn là một lời mời gọi hấp dẫn đối với những kẻ có dã tâm xấu, kích thích chúng tìm mọi thủ đoạn để luồn lách qua các hàng rào giới hạn mà bạn đã dày công thiết lập, nhằm mục đích thực hiện những hành vi phá hoại đối với phần mềm mà lẽ ra phải bị nghiêm cấm tuyệt đối.
– Sự phụ thuộc ngoài ý muốn: nếu bạn phơi bày các biến số/hàm một cách vô tội vạ khi không thực sự cần thiết, bạn đang gián tiếp khuyến khích các nhà phát triển khác xây dựng các tính năng phụ thuộc và ăn bám vào những thành phần lẽ ra phải mang tính nội bộ đó. Mặc dù điều đó có thể không làm sập chương trình của bạn ngày hôm nay, nhưng nó lại âm thầm gài một quả bom nổ chậm gây ra những rủi ro tái cấu trúc khổng lồ trong tương lai, bởi vì kể từ giờ phút đó, bạn không còn khả năng tự do đập đi xây lại cái biến số hay cái hàm đó nữa mà không nơm nớp lo sợ sẽ làm đứt gãy những phân vùng khác của phần mềm mà bạn hoàn toàn không có quyền kiểm soát. Lấy ví dụ, nếu đoạn mã của bạn hiện tại đang sống bám vào một mảng chứa toàn số, và rồi vào một ngày đẹp trời bạn thức dậy và nhận ra rằng việc sử dụng một cấu trúc dữ liệu khác ưu việt hơn mảng sẽ là một quyết định sáng suốt hơn, thì giờ đây bạn lại phải gánh vác thêm một khoản nợ trách nhiệm nặng nề là phải đi sửa chữa và điều chỉnh lại toàn bộ các phân vùng bị ảnh hưởng khác của phần mềm.
Nguyên lý POLE, khi được chiếu rọi vào việc thiết kế không gian phạm vi cho các biến số/hàm, về cơ bản truyền tải một mệnh lệnh tối cao: hãy thiết lập một trạng thái mặc định là chỉ phơi bày ra bên ngoài những thứ tối thiểu nhất có thể, và kiên quyết giam giữ toàn bộ những thứ còn lại trong vòng bí mật và riêng tư nhất. Hãy khai báo các biến số lọt thỏm ở bên trong những chiếc xô không gian phạm vi nhỏ bé nhất và lồng ghép ở tầng sâu nhất có thể, thay vì thói quen vứt bừa bãi mọi thứ ra không gian toàn cục (hay thậm chí là không gian hàm bao bọc bên ngoài). Nếu bạn rèn luyện được thói quen thiết kế phần mềm tuân thủ nghiêm ngặt nguyên lý này, bạn sẽ nắm trong tay một cơ hội lớn hơn rất nhiều để né tránh (hoặc chí ít là giảm thiểu tối đa sức tàn phá) của ba thảm họa đã được liệt kê ở trên.
Hãy cùng nhau xem xét một đoạn mã thực tế. Trong cái hàm tính hiệu số này, mục tiêu tối thượng của chúng ta là phải đảm bảo chắc chắn rằng giá trị y luôn luôn lớn hơn hoặc chí ít là bằng x, để khi chúng ta tiến hành phép trừ, kết quả thu được sẽ luôn là 0 hoặc một số dương. Nếu ngay từ đầu x đã chễm chệ mang một giá trị lớn hơn (kéo theo kết quả sẽ bị âm!), chúng ta bắt buộc phải tiến hành tráo đổi hai giá trị x và y thông qua một cái biến trung gian tmp, nhằm mục đích bảo toàn sự dương tính của kết quả. Trong cái ví dụ nhỏ bé này, thoạt nhìn thì việc cái biến tmp kia nằm ngoan ngoãn bên trong cái khối if hay là nó được nâng cấp lên để thuộc về không gian hàm có vẻ như chẳng mang lại sự khác biệt nào đáng kể—dù chắc chắn một điều là nó tuyệt đối không được phép là một biến toàn cục! Tuy nhiên, nếu chúng ta một lòng trung thành với nguyên lý POLE, biến tmp bắt buộc phải bị che giấu càng sâu càng tốt bên trong hệ thống không gian phạm vi. Vì vậy, chúng ta sẽ giam cầm cái biến tmp đó (bằng cách sử dụng từ khóa let) vào bên trong cái không gian phạm vi cấp khối của câu lệnh if đó.
Kỹ thuật che giấu thông qua phạm vi hàm
Sau những phân tích vừa rồi, có lẽ lý do vì sao chúng ta phải sống chết bảo vệ và che giấu các khai báo biến số và hàm nội bộ của mình vào những tầng không gian phạm vi thấp nhất (bị lồng ghép sâu nhất) đã trở nên rõ như ban ngày. Thế nhưng, câu hỏi kỹ thuật đặt ra là: chúng ta phải làm thế nào để hiện thực hóa điều đó?
Chúng ta đã từng được làm quen với sức mạnh của các từ khóa let và const, những công cụ sắc bén chuyên dùng để kiến tạo các không gian phạm vi khối; chúng ta sẽ sớm quay trở lại mổ xẻ chúng một cách chi tiết hơn. Nhưng trước tiên, làm thế nào để chúng ta có thể che giấu các khai báo kiểu cũ var hay các khai báo hàm truyền thống vào bên trong các không gian phạm vi? Thủ thuật này có thể dễ dàng được thực thi bằng cách lấy một không gian phạm vi hàm bọc kín cái câu lệnh khai báo đó lại. Hãy cùng phân tích một kịch bản nơi mà sức mạnh che giấu của không gian phạm vi hàm thực sự phát huy được tác dụng của nó.
Phép toán giai thừa (với ký hiệu toán học là 6!) là một chuỗi các phép nhân liên tiếp của một số nguyên cho trước với toàn bộ các số nguyên có giá trị thấp hơn nó giảm dần cho đến tận số 1—mà thực ra bạn hoàn toàn có thể dừng lại ở số 2 vì việc nhân với 1 là hoàn toàn vô bổ. Nói một cách hàn lâm hơn, 6! mang ý nghĩa tương đương hoàn toàn với 6 _ 5!, và bản thân 6 _ 5! lại tiếp tục tương đương với 6 _ 5 _ 4!, và cứ thế tiếp diễn. Bản chất toán học của phép toán này mách bảo chúng ta một điều: một khi giá trị giai thừa của một số nguyên bất kỳ (ví dụ như 4!) đã được hệ thống tính toán ra kết quả cuối cùng, thì vĩnh viễn chúng ta không bao giờ phải bắt hệ thống hì hục tính lại cái đống đó nữa, bởi vì kết quả trả về sẽ vĩnh viễn đóng băng và không bao giờ thay đổi. Vì vậy, nếu bạn áp dụng một thuật toán ngây thơ để tính toán giai thừa cho số 6, rồi ngay sau đó bạn lại tiếp tục muốn tính giai thừa cho số 7, thì hệ thống sẽ phải đốt cháy CPU một cách vô nghĩa để hì hục tính toán lại từ đầu giai thừa của toàn bộ các số nguyên trải dài từ số 2 cho đến số 6. Nếu bạn sẵn sàng đánh đổi dung lượng bộ nhớ để mua lấy tốc độ tính toán, bạn hoàn toàn có thể bóp chết cái sự lãng phí tài nguyên đó bằng cách lưu trữ lại (caching) kết quả giai thừa của từng số nguyên ngay khi nó vừa được tính toán xong.
Chúng ta đang xây dựng một cái kho lưu trữ cache để cất giữ toàn bộ các giá trị giai thừa đã được tính toán xong, nhờ vậy mà mỗi khi có nhiều lời gọi hàm tính giai thừa liên tiếp nhau, các kết quả tính toán từ những lần trước đó vẫn còn được bảo tồn nguyên vẹn. Tuy nhiên, một sự thật không thể chối cãi là cái biến cache kia đích thị là một chi tiết logic cực kỳ riêng tư phục vụ cho cách thức mà hàm tính giai thừa vận hành, chứ tuyệt đối không phải là một thông tin có thể bị phơi bày hớ hênh ra một cái không gian phạm vi bao bọc bên ngoài—và càng không được phép nằm chễm chệ ở không gian phạm vi toàn cục. Tuy nhiên, để sửa chữa cái lỗi phơi bày quá đà này lại không hề dễ dàng như việc chỉ cần giấu nhẹm cái biến cache vào trong cái hàm tính giai thừa, giống như những gì thoạt nhìn có vẻ hợp lý. Bởi vì chúng ta đang phải đối mặt với một yêu cầu khắc nghiệt là cái biến cache đó bắt buộc phải sống sót sau vô số các lần gọi hàm liên tiếp, nên về mặt vật lý nó phải được đặt ở một không gian phạm vi nằm ngay bên ngoài cái hàm đó. Vậy lối thoát hiểm nào dành cho chúng ta? Giải pháp tối ưu là hãy tự tay kiến tạo thêm một không gian phạm vi trung gian (nằm chen giữa cái không gian toàn cục/bên ngoài và nội tạng bên trong của cái hàm tính giai thừa) để làm nơi trú ngụ an toàn cho cái biến cache.
Cái hàm mang tên che giấu bộ nhớ tạm hoàn toàn không mang bất kỳ một ý nghĩa tồn tại nào khác ngoài việc đẻ ra một không gian phạm vi để cái biến cache có thể bấu víu vào và duy trì sự sống dai dẳng vượt qua nhiều lần hàm tính giai thừa bị gọi. Thế nhưng, để hàm tính giai thừa có thể thọc tay vào và lấy được cái biến cache đó, chúng ta bắt buộc phải khai báo hàm tính giai thừa nằm lọt thỏm ngay bên trong cái không gian phạm vi trung gian đó. Bước tiếp theo, chúng ta phải tống sợi dây tham chiếu của cái hàm đó ra ngoài (thông qua lệnh return) dưới dạng một giá trị trả về của cái hàm che giấu, và bắt lấy nó để gán vào một biến số cũng mang tên tính giai thừa nằm ở cái không gian phạm vi bên ngoài. Bằng thủ thuật này, giờ đây mỗi khi chúng ta thực hiện lời gọi hàm tính giai thừa (và lặp lại vô số lần!), cái bộ nhớ cache ngoan cường của nó vẫn luôn được che giấu trong bóng tối nhưng vẫn ngoan ngoãn phục vụ và chỉ cho phép duy nhất cái hàm tính giai thừa được quyền chạm tới nó! Tuyệt vời, nhưng… sẽ là một cơn ác mộng cực kỳ tẻ nhạt và phiền phức nếu như chúng ta cứ phải cắn răng đi khai báo (rồi lại còn phải vắt óc nghĩ ra tên!) cho một cái không gian phạm vi hàm trung gian kiểu như vậy mỗi khi nhu cầu che giấu biến số/hàm xuất hiện, đặc biệt là khi chúng ta lại còn phải cố gắng né tránh thảm họa xung đột định danh bằng cách ép buộc mỗi cái hàm như vậy phải mang một cái tên độc nhất vô nhị. Thật kinh khủng.
Thay vì phải cực nhọc đi khai báo một cái hàm mới toanh với một cái tên độc quyền mỗi khi phát sinh những nhu cầu kiến tạo ra các không gian phạm vi chỉ-với-mục-đích-che-giấu-một-biến-số, một giải pháp có lẽ thông minh và thanh lịch hơn rất nhiều là sử dụng cú pháp biểu thức hàm. Khoan đã! Cách tiếp cận này về cơ bản vẫn đang phải mượn sức của một cái hàm để kiến tạo ra cái không gian phạm vi dùng để giấu cái cache, và trong cái ví dụ cụ thể này, cái hàm đó vẫn đang chễm chệ mang cái tên che giấu bộ nhớ tạm, vậy thì rốt cuộc nó đã giải quyết được cái quái gì cơ chứ? Hãy kích hoạt lại trí nhớ của bạn về phần Phân tích tác động của các hình thái khai báo hàm (ở Chương 3), để nhớ lại xem số phận của cái định danh tên gọi sẽ ra sao khi nó xuất phát từ một biểu thức hàm. Bởi vì cái cấu trúc che giấu đó được khai báo dưới tư cách là một biểu thức hàm chứ không phải là một khai báo hàm chính thống, nên cái định danh tên gọi của nó sẽ bị nhốt chặt bên trong không gian phạm vi của riêng nó—về bản chất thì nó cùng chung một mâm với cái biến cache—chứ không bị hất văng ra ngoài không gian phạm vi toàn cục/bên ngoài.
Điều đó mang một ý nghĩa vĩ đại là chúng ta hoàn toàn có thể đặt cho mọi cá thể của cái kiểu biểu thức hàm đó một cái tên giống y hệt nhau, và kê cao gối ngủ mà không bao giờ phải lo sợ xảy ra xung đột tên gọi. Nói một cách chính xác hơn, chúng ta có thể tự do đặt cho mỗi cá thể một cái tên mang ý nghĩa mô tả rõ ràng chính xác những gì mà chúng ta đang cố gắng che giấu bên trong nó, mà không phải bận tâm việc cái tên mà mình vắt óc nghĩ ra có thể vô tình đâm sầm vào bất kỳ một không gian phạm vi biểu thức hàm nào khác trong toàn bộ chương trình hay không. Thậm chí, chúng ta có quyền thẳng tay lột bỏ luôn cái tên định danh đó—và qua đó vô tình tạo ra một biểu thức hàm ẩn danh chết tiệt. Nhưng Phụ lục A sẽ mổ xẻ một cách sâu sắc về sự quan trọng sống còn của việc phải đặt tên ngay cả đối với những cái hàm chỉ tồn tại duy nhất vì mục đích kiến tạo không gian phạm vi như thế này.
Tuyệt kỹ gọi biểu thức hàm thực thi ngay lập tức
Vẫn còn ẩn chứa một chi tiết kiến trúc cực kỳ quan trọng khác trong đoạn chương trình đệ quy tính giai thừa vừa rồi mà bạn rất dễ bị lướt qua: cái dòng mã kết thúc biểu thức hàm có chứa cụm ký tự })();. Xin hãy chú ý một cách cao độ vào việc chúng ta đã bọc toàn bộ cái biểu thức hàm đó bên trong một cặp dấu ngoặc đơn ( .. ), và rồi ở ngay cái khúc đuôi, chúng ta lại bồi thêm một bộ dấu ngoặc đơn () thứ hai; cái hành động cuối cùng đó thực chất là một mệnh lệnh gọi thực thi ngay tắp lự cái biểu thức hàm mà chúng ta vừa mới định nghĩa xong. Thêm vào đó, trong kịch bản đặc thù này, cái cặp dấu ngoặc đơn ( .. ) bao bọc bên ngoài biểu thức hàm thực tế không mang tính chất bắt buộc phải có về mặt cú pháp (sẽ được phân tích sâu hơn ngay sau đây), nhưng chúng ta vẫn cố tình nhét chúng vào để tối ưu hóa sự trong sáng và khả năng đọc hiểu của đoạn mã.
Nói một cách tóm gọn, chúng ta đang thực hiện thao tác định nghĩa ra một biểu thức hàm và ngay lập tức bóp cò ép nó chạy thực thi. Cái khuôn mẫu thiết kế vô cùng phổ biến này đã được ưu ái đặt cho một cái tên (vô cùng sáng tạo!): Biểu thức Hàm Gọi Ngay Lập Tức (Immediately Invoked Function Expression - IIFE). Một cấu trúc IIFE chứng tỏ được sự hữu dụng tuyệt đối của nó khi chúng ta khao khát muốn kiến tạo ra một không gian phạm vi kín đáo nhằm mục đích che giấu các biến số/hàm. Vì bản chất của nó là một biểu thức, nên nó có thể ngang nhiên xuất hiện ở bất kỳ một tọa độ nào bên trong một chương trình JS nơi mà ngữ pháp ngôn ngữ cho phép một biểu thức được quyền tồn tại. Một cấu trúc IIFE hoàn toàn có thể được đặt tên, giống như ví dụ vừa rồi, hoặc (trong đại đa số các trường hợp phổ biến hơn rất nhiều!) nó sẽ mang một thân phận vô danh/ẩn danh. Và nó có thể đứng sừng sững một cách độc lập hoặc, như đã thấy trước đó, trở thành một mảnh ghép của một câu lệnh lớn hơn—hàm che giấu bộ nhớ tạm phun ra sợi dây tham chiếu của hàm tính giai thừa để rồi ngay lập tức bị tóm lấy và gán vào bằng toán tử = cho cái biến số tên là tính giai thừa.
Trái ngược với ví dụ trước đó, nơi mà cái cặp ngoặc đơn (..) bao bọc bên ngoài được coi là một sự lựa chọn mang nặng tính phong cách cá nhân và không bắt buộc, thì đối với một cấu trúc IIFE đứng độc lập, chúng là một yêu cầu bắt buộc mang tính sống còn; chúng đóng vai trò là dấu hiệu nhận biết sống còn để báo cho hệ thống biết rằng cái hàm này phải được đối xử như một biểu thức, chứ tuyệt đối không phải là một câu lệnh khai báo. Tuy nhiên, để duy trì sự nhất quán và kỷ luật trong việc viết mã, tôi khuyên bạn nên tập thói quen luôn luôn bọc bất kỳ một hàm IIFE nào bằng cặp dấu ( .. ). Cần phải gióng lên một hồi chuông cảnh báo: việc lạm dụng cấu trúc IIFE để kiến tạo nên một không gian phạm vi có thể vô tình kích nổ một vài hệ lụy kiến trúc mà bạn vĩnh viễn không thể lường trước được, điều này phụ thuộc hoàn toàn vào những đoạn mã lân cận xung quanh nó. Bởi vì một cấu trúc IIFE về bản chất vẫn là một cái hàm điện toán hoàn chỉnh, nên cái ranh giới hàm vững chắc của nó sẽ làm bóp méo hoàn toàn hành vi của một số câu lệnh/cấu trúc điều khiển nhất định.
Lấy ví dụ, một câu lệnh return đang nằm chễm chệ bên trong một khối mã nào đó sẽ ngay lập tức bị biến dạng về mặt ý nghĩa nếu như bạn cố tình nhét một cấu trúc IIFE bọc lấy nó, bởi vì lúc này cái lệnh return đó sẽ ngây ngô trỏ thẳng đến cái hàm của chính cấu trúc IIFE đó chứ không phải là cái hàm chứa khối mã ban đầu. Các cấu trúc IIFE dạng hàm truyền thống (không phải hàm mũi tên) cũng ngang nhiên bóp méo luôn sự liên kết của từ khóa this—vấn đề này sẽ được mổ xẻ đến tận cùng trong tập sách Objects & Classes. Thêm vào đó, những câu lệnh điều khiển luồng như break và continue sẽ hoàn toàn bất lực và không thể xuyên thủng qua cái ranh giới hàm của cấu trúc IIFE để thao túng một vòng lặp hay một khối mã nằm ở không gian bên ngoài. Tóm lại, nếu như cái khối mã mà bạn đang rắp tâm muốn bọc lại bằng một không gian phạm vi có chứa bất kỳ một yếu tố nào trong số return, this, break, hay continue, thì một cấu trúc IIFE gần như chắc chắn là một quyết định kiến trúc cực kỳ tồi tệ. Khi rơi vào kịch bản đó, bạn nên khôn ngoan cân nhắc đến giải pháp kiến tạo không gian phạm vi thông qua một khối mã lệnh (block) thay vì cố đấm ăn xôi bằng một cái hàm.
Giới hạn phạm vi với khối mã
Đến thời điểm này, bạn chắc hẳn đã cảm thấy hoàn toàn bị thuyết phục và thoải mái với những giá trị vĩ đại mà việc kiến tạo các không gian phạm vi mang lại nhằm mục đích bóp nghẹt sự phơi bày của các định danh. Tính đến lúc này, chúng ta chỉ mới lướt qua kỹ thuật thực thi điều đó thông qua không gian phạm vi hàm (cụ thể là cấu trúc IIFE). Nhưng giờ là lúc chúng ta phải mở rộng tầm mắt để chiêm ngưỡng sức mạnh của các câu lệnh khai báo let khi kết hợp với các khối mã lồng ghép. Xét về mặt tổng quan, bất kỳ một cặp dấu ngoặc nhọn { .. } nào đóng vai trò cấu thành nên một câu lệnh đều sẽ mặc nhiên hành xử như một khối mã, nhưng chưa chắc nó đã sở hữu tư cách của một không gian phạm vi. Một khối mã chỉ thực sự thăng hạng và chuyển hóa thành một không gian phạm vi trong trường hợp nó bị ép buộc phải làm thế, tức là khi nó phải nai lưng ra để chứa chấp các câu lệnh khai báo tuân thủ phạm vi khối của nó (như let hoặc const). Tuy nhiên, không phải cứ nhìn thấy bất kỳ một cặp ngoặc nhọn { .. } nào bạn cũng có thể tự huyễn hoặc rằng đó là một khối mã (và do đó có cơ hội để nâng cấp thành các không gian phạm vi):
– Cú pháp khởi tạo đối tượng trực tiếp sử dụng cặp ngoặc nhọn { .. } để phân tách danh sách các cặp khóa-giá trị của nó, nhưng bản thân các giá trị đối tượng đó tuyệt đối không phải là các không gian phạm vi.
– Từ khóa class cũng lợi dụng cặp ngoặc nhọn { .. } để đóng khung toàn bộ phần định nghĩa phần thân của nó, thế nhưng đây cũng hoàn toàn không phải là một khối mã hay một không gian phạm vi.
– Một khai báo hàm cũng dùng cặp ngoặc nhọn { .. } để bao bọc lấy nội tạng của nó, nhưng về mặt kỹ thuật thuật ngữ, đây không được gọi là một khối mã—nó đơn thuần chỉ là một câu lệnh duy nhất cấu thành nên phần thân của hàm. Mặc dù vậy, nó chắc chắn là một không gian phạm vi (hàm).
– Cặp ngoặc nhọn { .. } nằm chễm chệ trong cấu trúc lệnh switch (bao bọc lấy toàn bộ các mệnh đề case) vĩnh viễn không bao giờ có khả năng định nghĩa ra một khối mã/không gian phạm vi.
Ngoại trừ những trường hợp giả danh khối mã vừa liệt kê, một cặp ngoặc nhọn { .. } hoàn toàn có khả năng định nghĩa ra một khối mã gắn liền với một câu lệnh điều khiển (như if hoặc for), hoặc thậm chí là kiêu hãnh đứng trơ trọi một mình—giống như cái cặp ngoặc nhọn ngoài cùng nhất trong cái đoạn mã mẫu trước đó. Một khối mã đứng độc lập trần trụi kiểu này—nếu như bên trong bụng nó hoàn toàn trống rỗng không có bất kỳ một câu lệnh khai báo nào, thì nó thực chất cũng chẳng phải là một không gian phạm vi—căn bản là vô dụng về mặt chức năng vận hành, mặc dù nó vẫn có thể vớt vát lại chút ý nghĩa dưới dạng một tín hiệu phân tách ngữ nghĩa cho người đọc. Các khối mã { .. } độc lập và minh bạch kiểu này về bản chất vẫn luôn được coi là một cú pháp hoàn toàn hợp lệ trong hệ sinh thái JS từ trước đến nay, nhưng bi kịch là do chúng hoàn toàn bị tước đoạt khả năng biến hình thành một không gian phạm vi trong kỷ nguyên trước khi các từ khóa let/const của phiên bản ES6 ra đời, nên sự hiện diện của chúng trong thực tế là cực kỳ hiếm hoi. Tuy nhiên, trong thời kỳ hậu ES6, thứ cú pháp cổ đại này đang dần dần len lỏi và giành lại một chút chỗ đứng trong lòng giới lập trình.
Trong đại đa số các hệ sinh thái ngôn ngữ điện toán có hỗ trợ khái niệm không gian phạm vi khối, việc sử dụng một không gian phạm vi khối minh bạch và độc lập là một khuôn mẫu thiết kế cực kỳ phổ biến và chuẩn mực để kiến tạo ra một lát cắt không gian siêu hẹp chuyên dùng để giam giữ một hoặc một vài biến số lẻ tẻ. Do đó, nếu một lòng tôn thờ nguyên lý POLE, chúng ta cũng bắt buộc phải dang tay đón nhận và phổ cập cái khuôn mẫu thiết kế này một cách sâu rộng hơn nữa vào trong hệ sinh thái JS; hãy vũ khí hóa các không gian phạm vi khối (độc lập và minh bạch) để bóp nghẹt sự phơi bày của các định danh xuống một mức độ tối thiểu nhất có thể chấp nhận được trong thực tế. Một không gian phạm vi khối minh bạch vẫn có thể phô diễn được sự hữu dụng tuyệt đối của nó ngay cả khi bị ném lọt thỏm vào bên trong lòng của một khối mã khác (bất chấp việc cái khối mã bao bọc bên ngoài đó có mang tư cách của một không gian phạm vi hay không).
Ở đoạn mã ví dụ, cặp ngoặc nhọn { .. } nằm chễm chệ ngay bên trong cấu trúc của câu lệnh if đã tạo ra một không gian phạm vi khối nội bộ độc lập và nhỏ bé hơn rất nhiều chuyên dùng để chứa chấp cái biến tin nhắn, bởi vì sự thật là cái biến số đó hoàn toàn không có bất kỳ một giá trị sử dụng nào đối với toàn bộ phần còn lại của cái khối if khổng lồ. Đại đa số các nhà phát triển lười biếng sẽ chỉ đơn giản là ném thẳng cái biến tin nhắn đó vào không gian phạm vi khối của cấu trúc if và rồi lạnh lùng bước tiếp. Và để công bằng mà nói, khi bạn chỉ phải đối mặt với một vài ba dòng mã lèo tèo, thì việc đặt nó ở đâu cũng chỉ mang tính chất hên xui như trò tung đồng xu mà thôi. Thế nhưng, khi quy mô của dự án phình to ra, những vấn nạn sinh ra từ sự phơi bày quá đà này sẽ bắt đầu hiện nguyên hình và gào thét một cách thảm khốc hơn rất nhiều. Vậy thì liệu những thảm họa đó có đủ sức nặng để thuyết phục bạn phải chịu khó chèn thêm một cái cặp { .. } và lùi lề thêm một cấp độ nữa hay không? Tôi bảo lưu quan điểm cứng rắn rằng bạn bắt buộc phải tuân thủ nghiêm ngặt nguyên lý POLE và hãy tập thói quen luôn luôn (trong một giới hạn logic cho phép!) định nghĩa ra một cái khối mã nhỏ bé nhất có thể cho mỗi một biến số riêng biệt. Vì lẽ đó, tôi cực kỳ khuyến khích việc sử dụng thêm các không gian phạm vi khối minh bạch phụ trợ giống như những gì đã được minh họa.
Hãy kích hoạt lại trí nhớ của bạn về cuộc mổ xẻ những cái lỗi Vùng chết tạm thời từ phần Giải mã Vùng chết tạm thời và quá trình khởi tạo (Chương 5). Lời khuyên xương máu của tôi ở phần đó là: nhằm mục đích triệt tiêu đến mức tối đa các nguy cơ kích nổ lỗi Vùng chết tạm thời dính dáng đến các từ khóa hiện đại, hãy rèn luyện kỷ luật thép là luôn luôn đẩy toàn bộ những câu lệnh khai báo đó lên cái tọa độ cao nhất của không gian phạm vi của chúng. Nếu tại một thời điểm nào đó, bạn giật mình nhận ra bản thân đang ngây ngô ném một câu lệnh khai báo let vào tận khúc giữa của một không gian phạm vi, hãy lập tức gióng lên một hồi chuông báo động trong đầu: Chết tiệt! Nguy cơ Vùng chết tạm thời hiển hiện!. Nếu như cái câu lệnh khai báo let này hoàn toàn vô dụng và không có mục đích sử dụng nào ở nửa phần trên của cái khối mã đó, bạn bắt buộc phải sử dụng một không gian phạm vi khối minh bạch nhỏ bé hơn ở bên trong để tiếp tục thắt chặt cái vòng kim cô phơi bày của nó!
Hãy cùng phân tích một ví dụ tiếp theo với sự hiện diện của một không gian phạm vi khối minh bạch. Đầu tiên, chúng ta hãy tiến hành định vị và lập bản đồ toàn bộ các không gian phạm vi cùng với hệ thống định danh của chúng:
- Cái không gian phạm vi ngoài cùng/toàn cục chỉ chứa chấp một định danh duy nhất, đó chính là cái hàm lấy ngày đầu tháng tiếp theo.
- Cái không gian phạm vi hàm của cái hàm lấy ngày đầu tháng tiếp theo đó lại sở hữu ba định danh: chuỗi ngày tháng (đóng vai trò là tham số), tháng tiếp theo, và năm.
- Cái cặp ngoặc nhọn
{ .. }cuối cùng đã định nghĩa ra một không gian phạm vi khối nội bộ đang giam giữ một biến số duy nhất: tháng hiện tại.
Vậy thì động lực sâu xa nào đã ép buộc chúng ta phải tống cái biến tháng hiện tại vào một không gian phạm vi khối minh bạch riêng biệt thay vì cứ thế ném nó nằm chễm chệ cạnh tháng tiếp theo và năm ở ngay cái không gian phạm vi hàm cấp cao nhất? Đơn giản là bởi vì sự tồn tại của cái biến tháng hiện tại chỉ mang lại ý nghĩa sử dụng cho đúng hai cái câu lệnh đầu tiên đó mà thôi; nếu bị đẩy lên cái cấp độ không gian phạm vi hàm, nó sẽ chịu chung số phận bị phơi bày quá đà một cách vô ích. Ví dụ minh họa này vô cùng nhỏ bé, nên những rủi ro sinh ra từ việc phơi bày quá đà biến tháng hiện tại có vẻ như khá mờ nhạt và bị hạn chế. Thế nhưng, những giá trị to lớn và kỳ diệu của nguyên lý POLE chỉ thực sự thăng hoa và đạt đến độ chín muồi khi bạn gieo mầm và nuôi dưỡng cái hệ tư tưởng tối thiểu hóa sự phơi bày phạm vi này thành một thói quen ăn sâu vào máu. Nếu bạn kiên trì tuân thủ cái nguyên lý này một cách nhất quán ngay cả trong những kịch bản nhỏ nhặt và tủn mủn nhất, nó sẽ đền đáp bạn bằng một sự bảo vệ vững chắc hơn gấp ngàn lần khi khối lượng dự án của bạn bành trướng.
Bây giờ, chúng ta hãy cùng nhau chuyển hướng lăng kính sang một ví dụ phức tạp và đồ sộ hơn một chút. Trong đoạn mã này, có đến sáu định danh được khai báo nằm rải rác trên tận năm không gian phạm vi hoàn toàn khác biệt nhau. Liệu có khả thi về mặt kỹ thuật nếu như chúng ta ném tất cả mớ biến số này vào sống chung trong một cái không gian phạm vi ngoài cùng/toàn cục duy nhất hay không? Hoàn toàn có thể, bởi vì nhờ một phép màu nào đó mà chúng đều mang những cái tên độc nhất vô nhị và do đó sẽ chẳng bao giờ có một cuộc xung đột tên gọi nào nổ ra cả. Thế nhưng, đây sẽ là một thảm họa tồi tệ nhất trong cách tổ chức mã nguồn, và một điều gần như chắc chắn là nó sẽ gieo rắc sự hoang mang tột độ cho người đọc, đồng thời dọn đường cho vô số những con rệp máy sinh sôi trong tương lai. Chúng ta đã khôn ngoan phân tán và nhốt chúng vào từng cái không gian phạm vi nội bộ lồng ghép sao cho phù hợp nhất. Mỗi một biến số đều bị ép buộc phải khai báo ở cái tầng không gian phạm vi lồng ghép sâu nhất có thể mà vẫn đảm bảo cho chương trình có thể vận hành trơn tru như mong đợi. Biến danh sách tên đã được sắp xếp hoàn toàn có đủ tư cách để được khai báo ở không gian phạm vi hàm cấp cao nhất, thế nhưng bi kịch của nó là nó chỉ mang lại ý nghĩa sử dụng cho mỗi cái nửa sau của cái hàm này mà thôi. Để dập tắt mọi nguy cơ phơi bày cái biến số đó ở một tầng không gian phạm vi cao hơn mức cần thiết, chúng ta lại một lần nữa trung thành với nguyên lý POLE và giam cầm nó vào trong cái không gian phạm vi khối minh bạch nội bộ.
Sự kết hợp chiến lược giữa từ khóa cũ và mới
Tiếp theo, hãy cùng nhau đưa lên bàn mổ xẻ cái câu lệnh khai báo var buckets. Cái biến số đó phải oằn mình ra phục vụ cho toàn bộ chiều dài của cái hàm (chỉ ngoại trừ mỗi cái câu lệnh return ở khúc chót). Bất kỳ một biến số nào phải gánh vác trách nhiệm xuyên suốt toàn bộ (hoặc thậm chí chỉ là phần lớn) vòng đời của một hàm thì bắt buộc phải được khai báo bằng một phương thức sao cho cái mức độ sử dụng bao trùm đó được phơi bày ra một cách hiển nhiên và đập thẳng vào mắt người đọc.
Vậy thì lý do sâu xa nào đã thúc đẩy chúng ta phải lôi cái từ khóa cổ đại var ra để khai báo cho cái biến buckets thay vì dùng từ khóa hiện đại let? Có cả những lập luận mang tính ngữ nghĩa lẫn những cơ sở kỹ thuật vững chắc bảo kê cho sự lựa chọn từ khóa var ở vị trí này. Xét về khía cạnh phong cách, từ khóa var đã luôn luôn, kể từ những ngày hồng hoang của lịch sử phát triển JS, mang trên mình một tín hiệu ngữ nghĩa thiêng liêng báo hiệu: ta là một biến số thuộc quyền sở hữu của toàn bộ cái hàm này. Như chúng ta đã dõng dạc tuyên bố trong phần Phạm vi từ vựng (Chương 1), từ khóa var luôn luôn có xu hướng bám rễ và móc chặt vào không gian phạm vi hàm bao bọc lấy nó ở khoảng cách gần nhất, bất chấp việc nó có bị vứt vào cái xó xỉnh nào đi chăng nữa. Chân lý đó vẫn vững như bàn thạch ngay cả khi cái từ khóa var đó bị nhét sâu vào bên trong bụng của một khối mã.
Mặc dù sự thật là từ khóa var vẫn đang lọt thỏm bên trong một cái khối mã, nhưng bản chất khai báo của nó vẫn bị hệ thống đóng đinh là tuân thủ phạm vi hàm (nằm trong sự kiểm soát của hàm tính hiệu số), chứ tuyệt đối không phải là phạm vi khối. Dù bạn hoàn toàn có quyền tự do khai báo từ khóa var nằm bên trong một khối mã (và vẫn giữ nguyên được bản chất phạm vi hàm của nó), nhưng tôi kịch liệt phản đối và khuyên bạn nên từ bỏ cách tiếp cận này, trừ phi bạn rơi vào một vài kịch bản cực kỳ đặc thù (sẽ được mổ xẻ tận gốc ở Phụ lục A). Nếu không, từ khóa var nên được coi là một thứ vũ khí thiêng liêng chỉ được đặc quyền sử dụng ở cái không gian phạm vi cấp cao nhất của một cái hàm.
Tại sao không đơn giản là vứt luôn một cái từ khóa let vào đúng cái tọa độ đó cho xong chuyện? Câu trả lời là bởi vì từ khóa var mang một dáng vẻ bên ngoài khác biệt hoàn toàn so với từ khóa let và do đó nó phóng ra một tín hiệu thị giác đanh thép, rõ ràng nhằm tuyên cáo: cái biến số này tuân thủ phạm vi hàm. Việc bạn ngoan cố nhét một từ khóa let vào cái không gian phạm vi cấp cao nhất, đặc biệt là khi nó không thèm nằm ở một vài dòng đầu tiên của một cái hàm, và trong cái bối cảnh mà toàn bộ những câu lệnh khai báo khác nằm lẩn khuất trong các khối mã đều đồng loạt sử dụng từ khóa let, thì cái cấu trúc đó hoàn toàn thất bại thảm hại trong việc thu hút ánh mắt người đọc chú ý đến sự khác biệt bản chất so với một câu lệnh khai báo tuân thủ phạm vi hàm.
Nói một cách thẳng thắn theo quan điểm cá nhân, tôi có một niềm tin mãnh liệt rằng từ khóa var truyền tải được cái thông điệp phạm vi hàm một cách xuất sắc hơn rất nhiều so với những gì mà từ khóa let có thể làm được, và ở chiều ngược lại, từ khóa let lại hoàn thành xuất sắc nhiệm vụ giao tiếp (và cả khả năng thực thi!) đối với khái niệm phạm vi khối ở những nơi mà sức mạnh của từ khóa var trở nên bất lực và thừa thãi. Chừng nào mà các chương trình phần mềm của bạn vẫn còn gào thét đòi hỏi sự hiện diện của cả hai loại biến số tuân thủ phạm vi hàm và phạm vi khối, thì cái phương pháp tiếp cận mang lại sự hợp lý và trong sáng nhất cho việc đọc hiểu là hãy kết hợp sức mạnh của cả từ khóa var lẫn từ khóa let lại với nhau, mỗi thứ vũ khí sẽ được phát huy tối đa ở đúng cái vị trí sở trường của nó. Vẫn còn tồn tại những lập luận về ngữ nghĩa và cơ chế vận hành khác biệt khác để chi phối quyết định lựa chọn giữa từ khóa var hoặc từ khóa let trong những kịch bản đa dạng. Chúng ta sẽ cùng nhau thực hiện một chuyến lặn sâu hơn nữa vào cái chủ đề muôn thuở về từ khóa var và từ khóa let trong Phụ lục A.
Định vị ranh giới sử dụng từ khóa mới
Lời khuyên xương máu của tôi về việc hãy coi từ khóa var như một báu vật và (hầu như) chỉ sử dụng nó cho duy nhất một cái không gian phạm vi hàm ở tầng cao nhất đồng nghĩa với một thông điệp ẩn ý rằng đại đa số các câu lệnh khai báo còn lại trong chương trình nên được trao cho từ khóa let. Nhưng rất có thể trong đầu bạn vẫn đang quay cuồng với câu hỏi: rốt cuộc thì phải dựa vào cái hệ quy chiếu nào để đưa ra quyết định xem mỗi một câu lệnh khai báo trong chương trình nên được đặt ở cái xó xỉnh nào cho đúng? Nguyên lý POLE vốn dĩ đã đóng vai trò là một người dẫn đường tận tụy cho bạn trong những quyết định sinh tử đó rồi, nhưng chúng ta hãy cùng nhau xé toạc nó ra và khẳng định lại một cách cực kỳ rõ ràng. Con đường đúng đắn để đưa ra quyết định tuyệt đối không bao giờ được phép bắt đầu từ câu hỏi mù quáng rằng bạn đang khao khát muốn nhét cái từ khóa nào vào đoạn mã. Câu hỏi đúng đắn duy nhất mà bạn phải tự chất vấn bản thân là: Đâu là cái mức độ phơi bày phạm vi nhỏ bé, tằn tiện nhất có thể mà vẫn cung cấp đủ không gian thở cho cái biến số này hoạt động trơn tru?.
Một khi câu hỏi hóc búa đó được giải mã, bạn sẽ tự động giác ngộ ra được chân lý xem liệu cái biến số đó xứng đáng được nhốt vào một không gian phạm vi khối hay là phải được nâng cấp lên không gian phạm vi hàm. Nếu ở bước phác thảo ban đầu bạn đã chốt hạ rằng một biến số nên bị giam cầm trong phạm vi khối, thế nhưng sau đó thực tế phũ phàng lại tát vào mặt bạn và buộc bạn phải thừa nhận rằng nó cần phải được thăng cấp lên thành phạm vi hàm, thì cái sự thức tỉnh đó không chỉ bắt buộc bạn phải dời đổi cái vị trí vật lý của cái câu lệnh khai báo biến đó, mà nó còn ép buộc bạn phải thay thế luôn cả cái từ khóa khai báo đang được sử dụng. Cái tiến trình ra quyết định mang tính chiến lược đó thực sự phải tuân theo đúng một cái lưu đồ logic như vậy. Nếu một câu lệnh khai báo đích thị sinh ra là để thuộc về một không gian phạm vi khối, hãy dứt khoát dùng từ khóa let. Ngược lại, nếu nó xứng đáng thuộc về không gian phạm vi hàm, hãy ưu ái dùng từ khóa var (xin nhắc lại, đây thuần túy chỉ là quan điểm chủ quan của cá nhân tôi).
Tuy nhiên, có một lối tư duy khác giúp bạn dễ dàng hình tượng hóa cái tiến trình ra quyết định này hơn, đó là hãy thử thả hồn tưởng tượng về cái dáng vẻ của một đoạn chương trình thời tiền ES6. Ví dụ, chúng ta hãy lôi cái hàm tính hiệu số từ đoạn trước ra mổ xẻ lại. Trong cái phiên bản cổ lỗ sĩ này của hàm tính hiệu số, cái biến tmp đã bị hệ thống vứt chỏng chơ một cách hiển nhiên vào cái không gian phạm vi hàm. Việc sắp xếp đó liệu có thực sự là một quyết định công bằng đối với cái biến tmp hay không? Tôi sẵn sàng vác gươm ra tranh luận rằng là không. Cái biến tmp đó chỉ thực sự mang lại ý nghĩa cho vài cái dòng lệnh ngắn ngủi kia thôi. Nó hoàn toàn vô dụng đối với cái câu lệnh return. Đáng lý ra nó phải bị hệ thống ép tuân thủ phạm vi khối. Trong kỷ nguyên tăm tối trước ES6, chúng ta hoàn toàn bất lực vì không có từ khóa let trong tay, do đó chúng ta không thể nào thực sự ép nó tuân thủ phạm vi khối được. Thế nhưng chúng ta vẫn có thể tung ra một chiêu bài 차선 sách (giải pháp tốt thứ hai) để phóng ra một tín hiệu cảnh báo về ý định thiết kế của mình.
Hành động cố tình nhét cái câu lệnh khai báo var cho biến tmp vào tuốt bên trong bụng của cấu trúc lệnh if chính là một mũi tên bắn ra tín hiệu cảnh báo cho bất kỳ kẻ nào đang săm soi đoạn mã rằng cái biến tmp này về mặt bản chất thuộc quyền sở hữu của cái khối mã đó. Bất chấp một sự thật tàn nhẫn là cỗ máy JS không thèm màng tới việc áp đặt cái ranh giới phạm vi đó, thì cái tín hiệu ngữ nghĩa đó vẫn mang lại một giá trị vô giá cho những đôi mắt đang căng ra đọc đoạn mã của bạn. Nương theo cái luồng tư tưởng này, bạn hoàn toàn có thể đi săn lùng bất kỳ cái từ khóa var nào đang nằm lẩn khuất bên trong một cái khối mã theo kiểu tương tự và thẳng tay trảm nó, thay thế bằng từ khóa let để củng cố và hiện thực hóa cái tín hiệu ngữ nghĩa vốn dĩ đã được hệ thống truyền đi. Dưới góc nhìn của tôi, đó mới chính là nghệ thuật sử dụng từ khóa let một cách chuẩn mực.
Một kịch bản kinh điển khác vốn dĩ có nguồn gốc lịch sử bắt rễ sâu xa từ việc sử dụng từ khóa var, nhưng giờ đây gần như bắt buộc phải luôn luôn chuyển sang sử dụng từ khóa let, đó chính là cấu trúc vòng lặp for. Mặc kệ việc cái vòng lặp kiểu đó bị khai báo ở cái xó xỉnh nào, thì về cơ bản cái biến i đó luôn luôn và chỉ nên được sử dụng độc quyền ở bên trong cái vòng lặp đó mà thôi, và trong cái bối cảnh đó thì nguyên lý POLE đã giáng xuống một mệnh lệnh tối cao rằng nó phải được khai báo bằng từ khóa let thay vì từ khóa var. Gần như cái khe cửa hẹp duy nhất có thể khiến cho việc chuyển đổi một cái từ khóa var thành từ khóa let theo kiểu này bẻ gãy đoạn mã của bạn là nếu như thuật toán của bạn đang khát khao phụ thuộc vào việc thọc tay truy cập vào cái biến đếm của vòng lặp (biến i) ở bên ngoài/phía sau khi vòng lặp đã kết thúc. Cái khuôn mẫu sử dụng dị hợm này không hẳn là quá hiếm gặp, thế nhưng phần đông giới tinh hoa đều đánh hơi thấy từ nó một mùi thum thủm của một cấu trúc mã tồi tàn. Một cách tiếp cận thanh lịch và ưu việt hơn rất nhiều là hãy viện đến sự phục vụ của một cái biến số tuân thủ không gian phạm vi bên ngoài khác để gánh vác cái mục đích đó. Biến giá trị cuối cùng là thứ cần thiết phải được bảo tồn xuyên suốt toàn bộ cái không gian phạm vi này, thế nên nó xứng đáng được ban cho từ khóa var. Còn biến i thì chỉ mang lại giá trị sử dụng trong (mỗi) một chu kỳ của vòng lặp, nên số phận của nó là phải bị gắn chặt với từ khóa let.
Ngoại lệ của cấu trúc bắt lỗi
Từ nãy đến giờ, chúng ta đã ra rả khẳng định như một chân lý rằng từ khóa kiểu cũ và các tham số đầu vào là những kẻ tuân thủ tuyệt đối không gian phạm vi hàm, trong khi các từ khóa hiện đại lại đóng vai trò như những cột mốc đánh dấu cho những câu lệnh khai báo tuân thủ không gian phạm vi khối. Tuy nhiên, có một cái lỗ hổng nhỏ xíu mang tính ngoại lệ cần phải được lôi ra ánh sáng: đó chính là mệnh đề catch (bắt lỗi). Kể từ cột mốc lịch sử khi cấu trúc try..catch được thai nghén và trình làng trong phiên bản ES3 (vào năm 1999), mệnh đề catch đã được hệ thống trang bị sẵn một thứ vũ khí bí mật (mà rất ít người biết đến): khả năng khai báo tuân thủ không gian phạm vi khối.
Cái biến số lỗi err được sinh ra bởi mệnh đề catch đã bị hệ thống ép buộc phải tuân thủ nghiêm ngặt không gian phạm vi khối của chính cái khối đó. Cái khối mã thuộc mệnh đề catch này hoàn toàn có đủ dung lượng để nhồi nhét thêm những câu lệnh khai báo tuân thủ không gian phạm vi khối khác thông qua từ khóa let. Thế nhưng, một câu lệnh khai báo var nếu không may đi lạc vào bên trong cái khối này thì nó vẫn sẽ chứng nào tật nấy, vươn vòi ra bám chặt vào cái không gian phạm vi hàm/toàn cục ở bên ngoài. Phiên bản ES2019 (ra đời khá gần với thời điểm những dòng chữ này được viết ra) đã tiến hành một cuộc phẫu thuật thay đổi cấu trúc của các mệnh đề catch, biến cái phần khai báo của chúng trở thành một tùy chọn không bắt buộc; nếu bạn lười biếng bỏ qua phần khai báo, thì cái khối catch đó sẽ chính thức bị tước đoạt (theo thiết lập mặc định) cái tư cách là một không gian phạm vi; mặc dù vậy, cái vỏ bọc bên ngoài của nó vẫn là một khối mã!
Vì vậy, nếu như logic của bạn chỉ đơn thuần cần phải tung ra một phản ứng đối phó với cái tình huống có một ngoại lệ chết tiệt nào đó vừa xảy ra (để bạn có thể dọn dẹp chiến trường và phục hồi hệ thống một cách duyên dáng), nhưng bạn lại hoàn toàn đếch quan tâm đến việc cái giá trị lỗi đó thực chất là cái quái gì, thì bạn hoàn toàn có quyền thẳng tay gạch bỏ cái phần khai báo của mệnh đề catch. Đây là một sự tinh giản cú pháp vô cùng nhỏ nhoi nhưng lại mang đến một cảm giác sảng khoái tột độ cho một kịch bản sử dụng cực kỳ phổ biến, và thậm chí nó còn có thể góp phần làm tăng một chút hiệu năng cho hệ thống bằng cách triệt tiêu đi một cái không gian phạm vi vô dụng!
Cạm bẫy khi khai báo hàm bên trong khối
Chúng ta đã được chứng kiến tận mắt sự thật rằng các câu lệnh khai báo sử dụng từ khóa hiện đại đều là những kẻ cuồng tín tuân thủ không gian phạm vi khối, và các câu lệnh khai báo bằng từ khóa kiểu cũ là những tay sai trung thành của không gian phạm vi hàm. Vậy thì số phận sẽ ra sao đối với những câu lệnh khai báo hàm cả gan xuất hiện trực tiếp ngay bên trong nội tạng của các khối mã? Với tư cách là một tính năng kiến trúc, trò chơi này được đặt cho cái bí danh FiB (Khai báo hàm bên trong khối). Tư duy thông thường của chúng ta hay có xu hướng mặc định đối xử với các câu lệnh khai báo hàm như thể chúng là những bản sao hoàn hảo của một câu lệnh khai báo bằng từ khóa kiểu cũ. Vậy thì, liệu chúng có ngoan ngoãn tuân thủ không gian phạm vi hàm y hệt như cái cách mà từ khóa kiểu cũ đang làm hay không?
Câu trả lời là một mớ bòng bong của cả Không và Có. Tôi hoàn toàn thấu hiểu… cái sự mập mờ đó thực sự là một cơn ác mộng gây lú lẫn tột độ. Hãy cùng nhau xắn tay áo lên và đào bới vấn đề: Bạn đặt cược vào kết quả nào cho cái đoạn chương trình quái đản này? Có ba cái kết cục nghe có vẻ vô cùng hợp tình hợp lý có thể xảy ra:
- Lời gọi hàm có khả năng sẽ sụp đổ thảm hại và ném ra một ngoại lệ Lỗi tham chiếu., bởi vì cái định danh tên hàm đó đã bị hệ thống ép phải tuân thủ không gian phạm vi khối đối với cái khối if đó và do đó nó vĩnh viễn mất tích khi bị truy tìm ở cái không gian phạm vi toàn cục/bên ngoài.
- Lời gọi hàm có khả năng sẽ chết yểu với một ngoại lệ Lỗi kiểu dữ liệu., bởi vì cái định danh tên hàm đó thực sự đã được hệ thống công nhận sự tồn tại, thế nhưng nó lại đang mang một thân xác trống rỗng. (do cái câu lệnh điều kiện if không hề được thi hành) và vì vậy nó hoàn toàn không có tư cách là một cái hàm có thể bị gọi ra chạy.
- Lời gọi hàm có khả năng sẽ vận hành một cách thần kỳ và hoàn hảo, và phun ra cái thông điệp Nó có chạy không? lên màn hình.
Và đây mới chính là cái hố sâu tăm tối gây lú lẫn nhất: phụ thuộc hoàn toàn vào việc bạn đang ném cái đoạn mã này vào chạy thử trên cái môi trường JavaScript khỉ gió nào, bạn sẽ gặt hái được những kết quả khác nhau một trời một vực! Đây là một trong số những vùng đất chết chóc và điên rồ hiếm hoi mà ở đó các hành vi tàn dư di sản từ thuở sơ khai lại ngang nhiên chà đạp và phản bội lại một cái kết quả lẽ ra có thể dự đoán được bằng logic. Bộ luật tối cao (tài liệu đặc tả JS) đã ghi rõ ràng rằng các câu lệnh khai báo hàm nằm lọt thỏm bên trong các khối mã bắt buộc phải bị áp đặt tuân thủ không gian phạm vi khối, vì vậy đáp án đúng đắn nhất theo luật phải là đáp án số (1). Thế nhưng, trớ trêu thay, đại đa số các cỗ máy thông dịch JS ngự trị trên trình duyệt (bao gồm cả con quái vật v8, thứ được khai sinh từ Chrome nhưng lại đang thống trị cả môi trường Node) lại hành xử theo cái phương án số (2), điều này có nghĩa là cái định danh đó thực chất đã được nới lỏng để thoát ra khỏi cái không gian của khối if, nhưng bi kịch là cái giá trị hàm của nó lại không được hệ thống tự động khởi tạo, nên nó vẫn mãi chịu kiếp trống rỗng..
Tại sao các cỗ máy JS của trình duyệt lại được ban cho cái đặc quyền tày trời là được phép làm trái lệnh của bộ đặc tả? Đơn giản là bởi vì những cỗ máy này vốn dĩ đã tự ý xây dựng cho mình những hành vi đặc thù xung quanh cái tính năng FiB này từ rất lâu trước khi kỷ nguyên ES6 mang cái khái niệm không gian phạm vi khối đến với thế giới, và hội đồng thiết kế đã phải đối mặt với một nỗi lo sợ tột độ rằng việc ép buộc thay đổi để tuân thủ luật pháp mới rất có khả năng sẽ làm sập hàng loạt những đoạn mã JS hiện đang chạy ổn định trên các trang web cũ. Đứng trước áp lực đó, họ đành phải cắn răng tạo ra một cái nhượng bộ ở Phụ lục B của bộ đặc tả JS, một tờ giấy phép đặc biệt dung túng cho một vài sự sai lệch hành vi đối với các cỗ máy JS của trình duyệt (và chỉ dành riêng cho trình duyệt mà thôi!). Một trong những kịch bản ứng dụng mang tính kinh điển nhất để xúi giục lập trình viên vứt một câu lệnh khai báo hàm vào bên trong một cái khối là khi họ muốn định nghĩa một cái hàm theo kiểu đa nhân cách (ví dụ như xài câu lệnh if..else) phụ thuộc vào trạng thái nhiệt độ của môi trường.
Việc nặn ra một cấu trúc mã theo kiểu này mang một sức hút cực kỳ mãnh liệt đối với những kẻ cuồng tín về hiệu năng, bởi vì cái biểu thức kiểm tra điều kiện kia sẽ chỉ phải gánh chịu tổn thất chạy đúng một lần duy nhất, trái ngược hoàn toàn với việc bạn chỉ định nghĩa duy nhất một cái hàm rồi lại lười biếng nhét cái câu lệnh if vào tận sâu bên trong bụng của nó—một giải pháp tồi tàn sẽ ép cái biểu thức kiểm tra đó phải chạy lại một cách vô nghĩa trong mỗi lần hàm bị gọi. Đi đôi với cái rổ rủi ro sinh ra từ những sai lệch của tính năng FiB, một cái ung nhọt khác phát sinh từ việc sử dụng các định nghĩa hàm đa nhân cách là nó sẽ biến công việc gỡ lỗi cho cái chương trình đó trở thành một cơn ác mộng trần gian. Nếu bạn không may thả một con rệp máy vào trong cái hàm đó, nhiệm vụ đầu tiên khiến bạn phải vắt óc là phải truy lùng xem rốt cuộc cái phiên bản nào của hàm đó mới thực sự là cái đang chạy trong thời gian thực! Đôi khi, cái con rệp đó lại chính là do bạn đã xài sai phiên bản hàm chỉ vì cái biểu thức kiểm tra điều kiện khốn kiếp kia đã trả về kết quả trật lất! Nếu bạn rắp tâm định nghĩa ra hàng tá các phiên bản dị bản cho một cái hàm duy nhất, thì cái chương trình đó vĩnh viễn sẽ trở thành một con quái vật khó hiểu và không thể bảo trì.
Bên cạnh những cái đoạn mã dở khóc dở cười vừa rồi, vẫn còn cả một bầy quái vật là những trường hợp góc (corner cases) vô cùng hiểm hóc của tính năng FiB đang lẩn khuất trong bóng tối; và những hành vi dị hợm đó khi bị phơi bày trên những môi trường trình duyệt khác biệt cũng như trên các môi trường JS phi trình duyệt (những cỗ máy JS không mang trong mình dòng máu trình duyệt) gần như chắc chắn sẽ lại tiếp tục mâu thuẫn và biến thiên một cách điên cuồng. Hãy kích hoạt lại trí nhớ của bạn về cái khái niệm hiện tượng kéo lên của hàm đã được giải phẫu trong phần Khi nào một biến số có thể sử dụng được (Chương 5), cái khái niệm đó có thể đã gieo rắc vào đầu bạn một niềm tin ngây thơ rằng cái hàm cuối cùng trong cái đống hỗn độn này, với thông điệp Khoan đã, có lẽ… sẽ sở hữu sức mạnh để bay vút qua đầu và đứng chắn trước cái lời gọi hàm. Bởi vì nó là cái câu lệnh khai báo hàm chốt sổ mang cái tên đó, nên theo luật rừng thì nó phải chiến thắng, đúng không? Phũ phàng thay, câu trả lời là không.
Việc lao vào làm một nhà biên niên sử để ghi chép lại toàn bộ những cái trường hợp góc kỳ dị này, hay cố gắng vắt óc ra để bào chữa cho việc tại sao mỗi cái trường hợp lại phát điên theo một kiểu riêng, hoàn toàn không phải là mục đích sống của tôi. Dưới góc nhìn của tôi, mớ kiến thức đó thuần túy chỉ là những mẩu tin tức giải trí vụn vặt và vô bổ mang tính chất khảo cổ học. Mối bận tâm thực sự và duy nhất của tôi đối với cái tính năng FiB này là: tôi có thể đưa ra một lời răn đe nào để bảo kê cho việc mã nguồn của bạn sẽ luôn luôn vận hành một cách ngoan ngoãn và nằm trong tầm kiểm soát trong mọi nghịch cảnh hay không? Về phần mình, cái lối thoát hiểm thực tế duy nhất mà tôi nhìn thấy để né tránh sự thất thường của tính năng FiB là việc bạn hãy hạ quyết tâm tẩy chay cái tính năng FiB này một cách triệt để. Nói một cách rành mạch và không khoan nhượng, tuyệt đối đừng bao giờ dại dột ném một câu lệnh khai báo hàm trực tiếp vào nội tạng của bất kỳ một cái khối mã nào. Hãy luôn luôn đặt các câu lệnh khai báo hàm ở bất kỳ cái tọa độ nào thuộc về không gian phạm vi tầng cao nhất của một cái hàm (hoặc vứt ra ngoài không gian phạm vi toàn cục).
Vì vậy, đối với cái ví dụ về cấu trúc điều kiện đa nhân cách lúc nãy, lời kêu gọi thiết tha của tôi là hãy tìm mọi cách để né tránh việc sử dụng các định nghĩa hàm đa nhân cách nếu như bạn vẫn còn một con đường lùi nào khác. Đúng vậy, cái giá phải trả có thể là một sự tụt giảm vô cùng vi bấn về mặt hiệu năng, thế nhưng đây lại là một triết lý tiếp cận mang lại sự vượt trội về tính toàn vẹn tổng thể. Nếu như cái sự tụt giảm hiệu năng bé hạt tiêu đó thực sự trở thành một điểm nghẽn cổ chai đe dọa trực tiếp đến sự sống còn của ứng dụng của bạn, thì tôi xin hiến kế cho bạn một phương pháp tiếp cận khác. Điều tối quan trọng mà bạn phải khắc cốt ghi tâm là ở đây tôi đang cấy ghép một biểu thức hàm, chứ tuyệt đối không phải là một câu lệnh khai báo, vào bên trong cái bụng của câu lệnh if. Việc các biểu thức hàm ngang nhiên xuất hiện bên trong các khối mã là một hành vi hoàn toàn hợp pháp và không bị hệ thống phàn nàn một lời nào. Toàn bộ cuộc thánh chiến chống lại FiB của chúng ta từ đầu đến giờ chỉ nhắm vào mục tiêu là cấm tiệt sự xuất hiện của các câu lệnh khai báo hàm bên trong các khối mã. Ngay cả khi bạn đã tiến hành chạy thử chương trình của mình và thấy nó vận hành một cách hoàn hảo, thì cái lợi ích cỏn con mà bạn vớt vát được từ việc áp dụng cái văn phong FiB vào đoạn mã của mình sẽ bị nghiền nát hoàn toàn bởi những quả bom nổ chậm rủi ro trong tương lai: sự hoang mang tột độ lây lan sang cho các nhà phát triển khác, hoặc những sự biến dạng dị hợm trong cách đoạn mã của bạn bị xử lý ở các môi trường JS xa lạ. Việc đâm đầu vào FiB là hoàn toàn không đáng để đánh đổi, và nó phải bị loại bỏ không thương tiếc.
Kết luận
Sứ mệnh tối thượng của những bộ luật quy định về phạm vi từ vựng trong một ngôn ngữ điện toán là nhằm trang bị cho chúng ta một công cụ quyền lực để có thể thiết lập một trật tự tổ chức hoàn mỹ cho các biến số của chương trình, thỏa mãn cả hai mục tiêu: sự trơn tru trong vận hành cơ học và tính trong sáng trong khả năng truyền đạt ngữ nghĩa của đoạn mã. Và một trong những chiến thuật tổ chức mang tính chất cốt tử nhất chính là việc thi hành kỷ luật sắt để đảm bảo rằng không có bất kỳ một biến số nào bị rơi vào thảm cảnh phơi bày quá đà ở những không gian phạm vi hoàn toàn thừa thãi và không cần thiết đối với nó (nguyên lý POLE). Tôi đặt một niềm tin hy vọng rằng đến giờ phút này, bạn đã thực sự thấu hiểu và trân trọng những giá trị vĩ đại của không gian phạm vi khối ở một đẳng cấp sâu sắc hơn rất nhiều so với trước đây. Hy vọng rằng lúc này bạn đang cảm thấy đôi chân của mình đã thực sự bám rễ vững chắc trên một nền tảng kiến thức vô cùng kiên cố về khái niệm phạm vi từ vựng. Đứng trên cái đỉnh cao nền tảng đó, chương tài liệu tiếp theo sẽ tung ra một cú nhảy vọt đưa chúng ta lao thẳng vào một cái chủ đề vô cùng nặng ký và hóc búa: hiện tượng bao đóng.