1.1. Điểm khác biệt với ngôn ngữ C

Estimated reading: 51 minutes 100 views

Hãy bắt đầu bước đầu tiên trong việc học C++ từ góc nhìn về sự khác biệt giữa C và C++.

Quả nhiên là hello.c

Chương trình C++ đầu tiên

Hãy nhìn vào List 1-1 (hello1.c) (đây là phiên bản thay đổi một chút của chương trình ngôn ngữ C điển hình List 0-1 (hello.c) đã được đưa ra làm ví dụ trong “Level 0 Ôn tập ngôn ngữ C”). Hàm printf() có thể chỉ định định dạng khi hiển thị dữ liệu từ tham số thứ 2 trở đi bằng chuỗi ký tự ở tham số thứ 1. Trong ví dụ này, nó được thiết kế để xuất ra 4 chuỗi ký tự bằng chỉ định %s.

List 1-1 + Chương trình ngôn ngữ C điển hình (hello1.c)

				
					1: #include <stdio.h> /* Chương trình ngôn ngữ C điển hình */
2:
3: int main( void )
4: {
5:     printf( "%s%s%s%s", "Hello", "\n", "World!", "\n" );
6:     return 0;
7: }				
			

Thêm một đoạn văn bản ở đây. Nhấp vào ô văn bản để tùy chỉnh nội dung, phong cách phông chữ và màu sắc của đoạn văn của bạn.

Khi biên dịch và chạy chương trình này bằng hệ thống xử lý ngôn ngữ C,

Hello
World!

sẽ được hiển thị. Và, chương trình này cũng là một chương trình C++ chính cống. Ngay từ đầu phát triển, C++ đã được thiết kế để có thể biên dịch nguyên trạng mã nguồn hiện có viết bằng ngôn ngữ C càng nhiều càng tốt. Ngay cả trong Tiêu chuẩn C++ do ANSI/ISO quy định (sau đây gọi tắt là ANSI C++), việc thiết kế đặc tả cũng được thực hiện theo hình thức bao gồm hầu hết đặc tả của ANSI/ISO C (sau đây gọi tắt là ANSI C) bao gồm cả thư viện C chuẩn (Fig. 1-1). Tất nhiên, vì cũng có một số thay đổi nhỏ, nên không phải tất cả mã nguồn viết theo ANSI C đều có thể biên dịch nguyên trạng. Tuy nhiên, nhờ những nỗ lực thiết kế như vậy, hầu hết mã nguồn viết bằng ANSI C đều có thể biên dịch được mà không cần thay đổi, hoặc chỉ cần chỉnh sửa một lượng nhỏ.

Chú ý rằng đây là những suy nghĩ của phiên bản C++ cũ (C++98/03), khi đó, C++ thường được coi là “C cộng thêm Class”. Hiện nay mối quan hệ giữa C và C++ đã thay đổi theo hướng “Hai ngôn ngữ riêng biệt có giao thoa lớn” thay vì “C++ bao trùm C”.

Biên dịch chương trình C++

Hãy thử biên dịch List 1-1 bằng C++. Phần mở rộng tên tệp nguồn giữ nguyên là “.c” thì đại khái là OK, nhưng với một số trình biên dịch (compiler), cần phải đổi thành “.cpp”, “.cp”, “.cxx”, “.c++” v.v…. Điều này là do có trường hợp trình biên dịch xử lý được cả C và C++ sẽ dựa vào phần mở rộng để quyết định xem nên biên dịch theo ngữ pháp nào. Ngoài ra, phần mở rộng của tệp tiêu đề C++ (header file) thường dùng là “.h”, “.hp”, “.hpp”, “.hxx”, “.h++” v.v…. Thực tình mà nói, vì Tiêu chuẩn C++ không quy định tên phần mở rộng của tệp nguồn hay tệp tiêu đề mà chương trình tự tạo, nên vốn dĩ không có phần mở rộng cũng chẳng sao. Tuy nhiên, trong hiện trạng tệp nguồn và tệp tiêu đề của C và C++ đang tồn tại song song, để phân biệt xem nó được viết bằng C hay viết bằng C++, có vẻ như việc đặt phần mở rộng khác nhau cho cả hai là một sách lược tốt. Trong sách này, tôi sẽ quy định phần mở rộng của tệp nguồn tự tạo bằng C++ là “.cpp”, và phần mở rộng của tệp tiêu đề là “.hpp”. Khi biên dịch thực tế, hãy thay đổi phần mở rộng một cách thích hợp cho phù hợp với Hệ điều hành (operating system) và trình biên dịch đang sử dụng. Nào, bạn đã biên dịch được List 1-1 dưới dạng mã nguồn C++ chưa? Nếu thành công, tôi nghĩ bạn đã xác nhận được rằng kết quả thực thi hoàn toàn giống với khi biên dịch và thực thi dưới dạng mã nguồn ngôn ngữ C.

hello2.cpp

Chương trình C++ điển hình

Tiếp theo, tôi sẽ trình bày ví dụ List 1-2 (hello2.cpp), đây là phiên bản viết lại chức năng tương tự List 1-1 nhưng mang đậm phong cách C++ hơn. Nếu biên dịch và thực thi bằng C++, bạn sẽ thấy kết quả thực thi giống hệt với List 1-1. Vừa so sánh List 1-1 và List 1-2, tôi vừa giới thiệu một vài đặc trưng độc đáo của C++.

				
					1: #include <iostream> // Chương trình C++ điển hình
2:
3: using namespace std;
4:
5: int main()
6: {
7:     cout << "Hello" << endl << "World!" << endl;
8:     return 0;
9: }				
			

<iostream>

Trước hết, hãy nhìn vào dòng 1. Trong List 1-1, chúng ta đã include tệp tiêu đề của module nhập xuất chuẩn được kế thừa từ thư viện C chuẩn là <stdio.h>, nhưng trong List 1-2, thay vào đó chúng ta include tệp tiêu đề có tên là <iostream>. Đúng vậy, chính <iostream> này là tệp tiêu đề của module nhập xuất chuẩn mới tận dụng các tính năng của C++. Như vậy, các tiêu đề thư viện chuẩn đặc thù của ANSI C++ sẽ không có phần mở rộng. “iostream” là viết tắt của “Input Output Stream”. Trong thư viện nhập xuất chuẩn của ngôn ngữ C, việc xuất dữ liệu ra tệp tin hay nhập từ tệp tin, xuất ra màn hình hay nhập từ bàn phím được thực hiện thông qua Kiểu cấu trúc (structure type / 構造体型) gọi là FILE. Dữ liệu được biểu diễn một cách trừu tượng bởi các cấu trúc kiểu FILE như vậy, khi nhập xuất với bên ngoài sẽ đi qua một “con đường” được gọi là Luồng (stream / ストリーム). stream có nghĩa là “dòng chảy”, “con suối”, biểu thị trạng thái dữ liệu chảy ra và vào. Và, trong C++ cũng tương tự, đối tượng nhập xuất dữ liệu được gọi là stream.

Chú thích bằng //

Ở phía sau dấu gạch chéo kép “//” tại dòng 1 có chứa chú thích. Trong C++, khoảng từ ngay sau “//” cho đến khi xuống dòng của dòng đó được coi là chú thích. Tất nhiên, bạn cũng có thể sử dụng định dạng chú thích truyền thống của ngôn ngữ C (kẹp phần chú thích giữa /* và */). Phương thức “/* */” truyền thống rất tiện lợi để Vô hiệu hóa mã (comment out / コメントアウト) (biến một phần chương trình nguồn thành comment để vô hiệu hóa khi cần thiết) một phạm vi lớn cùng lúc, nhưng cũng có sự bất tiện như không thể lồng nhau (Level 0, ☞ tr. 9). Hình thức chú thích “//” không thích hợp để vô hiệu hóa mã một phạm vi lớn. Tuy nhiên, điểm đáng mừng là ta có thể viết vào tệp nguồn một cách thoải mái mà không lo về việc lồng nhau. Ngoài ra,

/*
   // Một dòng comment định dạng "//" nằm trong comment định dạng "/**/" truyền thống
*/

như ví dụ trên, việc có dòng comment định dạng “//” nằm trong comment định dạng “/* */” cũng không sao, và

// /* Comment định dạng "/**/" nằm trong comment định dạng "//" */

như ví dụ này, việc comment định dạng “/* */” tồn tại trong comment định dạng “//” cũng không vấn đề gì. Bạn nên sử dụng linh hoạt các định dạng chú thích này cùng với #if 0 ~ #endif để ghi chú thích.

using namespace std;

Ở dòng 3 có một dòng lạ lẫm:

using namespace std;

nhưng hiện tại hãy bỏ qua nó. Tôi sẽ giải thích sau.

Danh sách tham số rỗng

Trong hàm main() ở dòng 5, danh sách tham số (formal parameter list) bị lược bỏ. Điều này chỉ ra rằng hàm main() này không nhận tham số. Tức là, trong C++, việc lược bỏ danh sách tham số cũng giống với việc viết void vào danh sách tham số, nó minh thị rằng hàm đó không nhận tham số. Thực ra, ngay cả trong ngôn ngữ C, khi “định nghĩa” hàm, trường hợp không nhận tham số thì ta có thể để trống danh sách tham số để chỉ rõ điều đó. Tuy nhiên, điều rắc rối là, trong ngôn ngữ C nếu lược bỏ danh sách tham số ở phần “khai báo” hàm, thì nó có nghĩa là hàm đó có thể nhận số lượng tham số tùy ý với kiểu tùy ý. Ví dụ, giả sử có một hàm được định nghĩa trong tệp nguồn mymodule.c như sau:

int f() { return 0; }

Hàm f() này sẽ không nhận tham số. Giả sử khai báo tham chiếu bên ngoài của nó trong tệp tiêu đề mymodule.h là:

int f();

Và, nếu một tệp nguồn khác main.c thực hiện include mymodule.h, thì trong main.c, việc gọi hàm như sau:

f(1, 2, 3);

lại trở nên khả thi. Để giải quyết sự không nhất quán này giữa định nghĩa hàm và khai báo hàm, phong cách lập trình phổ biến thời ANSI C đã khuyến nghị rằng nếu không nhận tham số thì dù là khai báo hay định nghĩa, cũng nên viết void vào danh sách tham số. Trong C++, dù là khai báo hay định nghĩa, nếu để trống tham số  thì nó có nghĩa là không nhận tham số. Vì vậy, ta có thể lược bỏ danh sách tham số khi không nhận tham số mà không cần lo lắng gì cả.

cout

Trong List 1-1, dòng tương đương với việc sử dụng hàm nhập xuất chuẩn printf() để xuất chuỗi ký tự chính là dòng thứ 7 trong List 1-2. Tóm lại, chúng ta đã include <iostream> chính là vì dòng này. Thử nhìn vào biểu thức của dòng này, ta thấy nó có thể chia thành 4 yếu tố lớn. Đó là cout nằm ở ngoài cùng bên trái, chuỗi ký tự, endl, và toán tử << kết nối chúng. Hãy cùng xem xét từ phía bên trái. Trước tiên là cout, rốt cuộc đây là cái gì? Trong thư viện C chuẩn, stdout được chuẩn bị sẵn như là luồng dùng cho đầu ra chuẩn, và đây là luồng lấy màn hình v.v. làm đối tượng xuất. Ví dụ, hàm printf() là hàm xuất có định dạng lấy đích xuất là luồng xuất chuẩn stdout. Thực ra, cout chính là luồng xuất chuẩn của C++ tương đương với stdout của thư viện C chuẩn. Tương tự, cin là luồng nhập chuẩn của C++ tương đương với luồng nhập chuẩn stdin của ngôn ngữ C.

Toán tử <<

Nằm bên phải cout là toán tử dịch bit trái (left shift operator) <<. Đến đây, chắc hẳn mọi người sẽ có thắc mắc rất lớn. “Tại sao toán tử dịch bit lại xuất hiện ở chỗ như thế này nhỉ. Đáng lẽ bên trái toán tử dịch bit phải là toán hạng kiểu số nguyên bị dịch chuyển, còn bên phải phải là toán hạng kiểu số nguyên chỉ định số bit dịch chuyển chứ. Tại sao toán hạng bên trái lại là luồng xuất, còn toán hạng bên phải lại là chuỗi ký tự??” đại loại như vậy. Tóm lại, đáng lẽ phải là:

(a) Kiểu số nguyên << Kiểu số nguyên

thì trong List 1-2, nó lại trở thành:

(b) Luồng xuất << Chuỗi ký tự

Và, biểu thức này dường như đang xuất chuỗi ký tự của toán hạng bên phải vào luồng xuất của toán hạng bên trái (trong List 1-2 là cout). Câu trả lời cho vấn đề này thể hiện một phần chức năng của C++. Bật mí bí mật thì, trong C++ ta có thể gán ý nghĩa khác cho các toán tử hiện có. Tôi chưa đi sâu vào chi tiết, nhưng toán tử trong C++ có thể được định nghĩa như một hàm, và cũng có thể gán ý nghĩa khác cho hàm cùng tên. Toán tử dưới dạng hàm được gọi là Hàm toán tử (operator function), và việc gán định nghĩa khác cho hàm cùng tên được gọi là Đa năng hóa tên hàm (function overloading / オーバーロード).

Đặc tả khác nhau của toán tử <<

Tóm lại, toán tử << ở (a) và (b) dù có vẻ ngoài là “<<” giống nhau, nhưng bên trong là các hàm khác nhau. Hãy thử mô tả toán tử << của (a) và (b) dưới dạng hàm. Nếu coi toán hạng trái là tham số thứ 1, toán hạng phải là tham số thứ 2, thì khái quát đặc tả của chúng lần lượt như sau.

(a’) Tham số 1: Kiểu số nguyên

Tham số 2: Kiểu số nguyên

Chức năng: Dịch chuyển tham số 1 sang trái số bit được chỉ định bởi tham số 2

Giá trị trả về: Tham số 1 đã được dịch chuyển

(b’) Tham số 1: Luồng xuất

Tham số 2: Chuỗi ký tự

Chức năng: Xuất chuỗi ký tự của tham số 2 vào luồng xuất của tham số 1

Giá trị trả về: Luồng xuất của tham số 1

Bạn có thể thấy kiểu của tham số ở (a’) và (b’) là khác nhau đúng không. Như vậy, trong trường hợp có nhiều định nghĩa hàm có cấu trúc tham số khác nhau dù cùng tên, thì chỉ có định nghĩa hàm nào có thông tin kiểu của tham số giả định khớp với thông tin kiểu của tham số thực mới được tự động gọi ra từ trong số các định nghĩa đó. Toán tử << có luồng xuất ở tham số thứ 1 (= toán hạng trái) được định nghĩa nhiều biến thể khác nhau tùy thuộc vào loại của toán hạng phải. Ví dụ,

(c) Luồng xuất << Kiểu double

Toán tử << có các toán hạng như trên được định nghĩa như sau:

(c’) Tham số 1: Luồng xuất

Tham số 2: Kiểu double

Chức năng: Chuyển đổi giá trị số kiểu double của tham số 2 thành chuỗi ký tự rồi xuất vào luồng xuất của tham số 1

Giá trị trả về: Luồng xuất của tham số 1

Các kiểu cơ bản được tích hợp trong ngôn ngữ C++ như kiểu số, kiểu ký tự, kiểu chuỗi ký tự, kiểu con trỏ v.v… đều được chuyển đổi thành chuỗi ký tự thích hợp và xuất ra luồng được chỉ định nhờ vào toán tử << được định nghĩa đa năng như thế này. Vì vậy, nếu viết:

cout << 1.0;

thì nội dung của nó sẽ là chuyển đổi giá trị thực 1.0 thành biểu diễn chuỗi ký tự “1.0”, và xuất ra luồng xuất chuẩn:

1.0

Toán tử << là toán tử kết hợp trái, và cách kết hợp sẽ như Fig. 1-2. Đầu tiên,

(cout << "Hello")

được thực thi, và chuỗi ký tự “Hello” được hiển thị (Fig. 1-2-(1)). Sau đó, giá trị trả về cout thay thế cho biểu thức con này, toàn bộ biểu thức trở thành Fig. 1-2-(2), và tiếp theo đến lượt:

(cout << endl)

được thực thi. Vậy, endl này là cái gì?

+++++ Fig. 1-2 + Luồng xuất chuẩn cout, toán tử <<, endl +++++ 

cout << "Hello" << endl << "World!" << endl; // Quá trình tính toán

(1) (((cout << "Hello") << endl) << "World!") << endl; cout —-> Xuất chuỗi ký tự “Hello” ra luồng xuất chuẩn

(2) ((cout << endl) << "World!") << endl; cout —-> Xuất ký tự xuống dòng ‘\n’ ra luồng xuất chuẩn Flush (đẩy dữ liệu) luồng xuất chuẩn

(3) (cout << "World!") << endl; cout —-> Xuất chuỗi ký tự “World!” ra luồng xuất chuẩn

(4) cout << endl; cout —-> Xuất ký tự xuống dòng ‘\n’ ra luồng xuất chuẩn Flush (đẩy dữ liệu) luồng xuất chuẩn

(5) cout;

+++++ Kết thúc Fig. 1-2 +++++ 

endl

Tôi nghĩ bạn có thể đoán ra khi so sánh List 1-1, List 1-2 và kết quả thực thi của chúng, khi ném endl vào luồng xuất bằng toán tử <<, ký tự xuống dòng \n sẽ được xuất ra. Tóm lại, 

cout << endl;

có thể nói là tương đương về mặt hiệu quả với việc viết bằng ANSI C:

printf( "%c", '\n' );

Vậy, endl có phải là hằng số ký tự \n không?. Thực ra, endl là tên hàm. Trong ngôn ngữ C chuẩn và C++, nếu chỉ viết tên hàm, nó sẽ được xử lý như một con trỏ tới hàm đó. Tóm lại, toán tử << ở đây là:

(d) Luồng xuất << Con trỏ hàm

và định nghĩa của toán tử << được gọi lúc này sẽ như sau:

(d’) Tham số 1: Luồng xuất

Tham số 2: Con trỏ hàm

Chức năng: Truyền luồng xuất của tham số 1 cho hàm được chỉ định bởi tham số 2, và thực thi hàm đó

Giá trị trả về: Luồng xuất của tham số 1

Hàm endl() được gọi từ toán tử <<, ghi ký tự xuống dòng \n vào luồng xuất được chỉ định, và làm sạch (flush) luồng xuất đó. Thông thường, thiết bị xuất tốn nhiều thời gian cho việc xuất hơn so với bộ nhớ, nên khi xuất ra luồng, dữ liệu được tích lũy vào vùng nhớ tạm thời (bộ đệm, buffer) trước, sau đó nội dung của bộ đệm mới được gửi đến thiết bị xuất một cách thích hợp. Việc gửi nội dung của bộ đệm dùng cho luồng đến thiết bị xuất như vậy được gọi là Flush (flush / フラッシュ). Nếu không flush, sẽ xảy ra tình trạng dữ liệu đáng lẽ đã xuất rồi thực tế vẫn còn nằm trong bộ đệm và chưa được xuất ra. Nếu chỉ xuất đơn lẻ ký tự xuống dòng \n ra luồng xuất chuẩn như thế này:

cout << 1.0 << '\n';

thì có khả năng chuỗi ký tự “1.0” và ký tự xuống dòng \n sẽ nằm lại trong bộ đệm mà không được flush, dẫn đến việc trên màn hình không hiển thị 1.0. Như có thể thấy từ ví dụ này, nếu xuất dữ liệu ra luồng xuất chuẩn, ta nên thực hiện flush thường xuyên. Vì vậy, tôi khuyên bạn nên dùng endl để flush thường xuyên như sau:

cout << 1.0 << endl;

Như vậy, toàn bộ biểu thức sẽ lần lượt được đánh giá như sau:

				
					(cout << "World!") << endl;   // (Fig. 1-2-(3))
cout << endl;                 // (Fig. 1-2-(4))
cout;                         // (Fig. 1-2-(5))				
			

và trên luồng xuất chuẩn sẽ hiển thị:

Hello
World!

Đặc điểm của việc xuất bằng <<

Hãy tóm tắt đơn giản những ưu điểm của việc xuất sử dụng toán tử <<.

(1) Không cần phải khớp chỉ định chuyển đổi và dãy giá trị Hàm printf() của thư viện chuẩn ngôn ngữ C rất tiện lợi, nhưng ta phải khớp chính xác chỉ định chuyển đổi nằm trong chuỗi ký tự của tham số 1 với các tham số thực từ tham số 2 trở đi. Nếu chỉ định chuyển đổi và tham số không tương ứng chính xác, việc chuyển đổi sẽ không suôn sẻ và gây ra lỗi hoạt động của chương trình. Nếu là lập trình viên ngôn ngữ C, chắc chắn ai cũng từng có kinh nghiệm phạm phải sai lầm như thế này. Nếu sử dụng toán tử <<, điều như vậy sẽ không xảy ra nữa. Ngoài ra, ta có thể chèn dữ liệu vào dãy xuất hoặc bỏ ra một cách thoải mái.

(2) Dễ hình dung Nhờ vào vẻ ngoài của toán tử <<, khi nhìn vào toàn bộ biểu thức ta có ấn tượng rằng dữ liệu đang chảy ra luồng xuất ở phía ngoài cùng bên trái.

(3) Cũng có thể xuất có định dạng: Thực ra, việc xuất có định dạng giống như hàm printf() cũng khả thi. Về các chức năng của luồng nhập xuất C++ như xuất có định dạng v.v., tôi sẽ giải thích ở Level 12.

Không gian tên (Namespace)

Vấn đề xung đột tên phát sinh

Nào, trong List 1-2 vẫn còn phần chưa giải thích.

using namespace std;

chính là chỗ này. Cái này có liên quan đến một chức năng gọi là Không gian tên (namespace / 名前空間) được đưa vào trong C++. Như đã đề cập ở Level 0, phạm vi hiệu lực của các định danh (identifier) như tham số của hàm hay biến được khai báo trong khối {} chỉ nằm trong khối đó (Phạm vi khối / Block Scope). Nếu khi định nghĩa hàm, ta không thể sử dụng tên biến đã được khai báo trong một hàm khác, thì ta sẽ phải kiểm tra từng biến trong tất cả các hàm hiện có, tìm ra tên biến chưa được sử dụng. Nhờ định danh khai báo trong hàm có phạm vi khối, ta có thể tự do đặt tên biến khi định nghĩa hàm. Mặt khác, Định danh toàn cục (global identifier) có phạm vi toàn cục thì sao. Ví dụ là tên hàm. Trong ngôn ngữ C, các định danh toàn cục được định nghĩa trong trạng thái hỗn độn trong một phạm vi toàn cục duy nhất. Tóm lại, trong phạm vi toàn cục luôn có khả năng xảy ra xung đột tên. Nếu tổng số định danh toàn cục ít, ta có thể xem qua tất cả và chú ý để không đặt tên trùng với định danh đã có. Tuy nhiên, khi sử dụng thư viện khổng lồ thì sao. Liệu ta có thể kiểm tra tất cả các định danh lên tới con số khổng lồ để tránh xung đột tên không. Có lẽ câu trả lời là KHÔNG. Liệu có diệu kế nào giải quyết vấn đề này không.

Định nghĩa không gian tên

Thứ giải quyết vấn đề này cho chúng ta chính là chức năng định nghĩa không gian tên (Fig. 1-3). Nói một lời thì nó sẽ là “Cung cấp phạm vi khối có tên để phân chia phạm vi toàn cục”. Như hiển thị trong Fig. 1-3, cú pháp định nghĩa không gian tên như sau. 

namespace N { }

Các định danh được khai báo trong khối này sẽ đều thuộc về phạm vi gọi là không gian tên N. Định nghĩa không gian tên như thế này có thể thực hiện ở bất cứ đâu tại vị trí toàn cục (tức là phần bên ngoài định nghĩa hàm).

Fig. 1-3 + Định nghĩa không gian tên (Namespace Definition)

				
					namespace Tên_không_gian_tên {
    Dãy các khai báo và định nghĩa
}
// Có thể định nghĩa cùng một không gian tên ở nhiều nơi.
// Ở trong file khác cũng được.
// Ví dụ:
namespace N {
    double a = 0.0;
    int fool() { return 0; }
}				
			

List 1-3 + Ví dụ định nghĩa không gian tên 1 (nspace1.cpp)

				
					1: namespace N {
2:     // Định nghĩa tên kiểu IntArray5
3:     typedef int IntArray5[ 5 ];
4:     // Khai báo biến toàn cục a và
5:     // hàm foo1 trong không gian tên N
6:     extern double a;
7:     int foo1( IntArray5 ia5 );
8: }
9:
10: namespace M {
11:     // Định nghĩa hàm foo2 trong không gian tên M
12:     double foo2() { return 0.0; }
13: }
14:
15: namespace N {
16:     // Định nghĩa biến toàn cục a và
17:     // hàm foo1 trong không gian tên N
18:     extern double a = 0.0;
19:     int foo1( IntArray5 ia5 ) { return ia5[ 0 ]; }
20: }				
			

List 1-4 + Ví dụ định nghĩa không gian tên 2 (nspace2.cpp)

 
				
					1: namespace N {
2:     // Định nghĩa tên kiểu IntArray5
3:     typedef int IntArray5[ 5 ];
4:     // Khai báo biến toàn cục a và
5:     // hàm foo1 trong không gian tên N
6:     extern double a;
7:     int foo1( IntArray5 ia5 );
8: }
9:
10: namespace N {
11:     // Định nghĩa hàm foo3 trong không gian tên N
12:     int foo3() { return 0; }
13: }
14:
15: int main()
16: {
17:     N::IntArray5 ia5 = { 1, 2, 3, 4, 5 };
18:     N::foo1( ia5 );
19:     N::foo3();
20:     return 0;
21: }				
			

Ví dụ, trong List 1-3, chúng ta định nghĩa hai không gian tên là N và M. Nếu tên của không gian tên giống nhau, thì dù ở các vị trí cách xa nhau, chúng vẫn được coi là định nghĩa cho cùng một không gian tên, do đó hàm foo1() sẽ thuộc về không gian tên N ở cả phần khai báo và định nghĩa. Hơn nữa, ngay cả khi các tệp nguồn khác nhau, các không gian tên cùng tên vẫn được xử lý như là một không gian tên duy nhất. Vì vậy, hàm foo3() trong List 1-4 cũng thuộc về cùng một không gian tên N với hàm foo1() trong List 1-3. Nếu sử dụng không gian tên, ta có thể đưa vào một phạm vi logic (lại có tên) trải dài trên nhiều tệp. So với hệ thống của ngôn ngữ C vốn chịu hạn chế bởi phạm vi toàn cục duy nhất và phạm vi tệp vật lý, thì đây có thể coi là một bước tiến vượt bậc.

Tham chiếu định danh trong không gian tên

Khi sử dụng một định danh thuộc về một không gian tên cụ thể, ta phải làm thế nào?. Để làm điều đó, ta sử dụng Toán tử giải quyết phạm vi (scope resolution operator / 有効範囲解決演算子) :: vốn không có trong ngôn ngữ C (Fig. 1-4). Cụ thể là:

Tên_không_gian_tên::Định_danh

Theo cách như vậy. Trong ví dụ Fig. 1-4, để gọi hàm foo1() trong không gian tên N, ta sẽ chỉ định là: 

N::foo1();

Ngoài ra, trong cùng một không gian tên, ta có thể tham chiếu trực tiếp chỉ bằng định danh mà không cần dùng toán tử ::. Mặt khác, trường hợp tham chiếu đến định danh toàn cục, ta có thể sử dụng toán tử :: mà không chỉ định tên không gian tên, viết là:

::Định_danh_toàn_cục

Nhân tiện, khi sử dụng N::foo1() đã nói ở trên, việc phải thêm “N::” vào mỗi lần gọi thật là phiền phức. Vì thế, nếu ta khai báo trước khi gọi lần đầu tiên như sau:

using Tên_không_gian_tên::Định_danh;

thì sau đó chỉ cần dùng định danh là đã chỉ đến toàn bộ “Tên_không_gian_tên::Định_danh” rồi. Cái này được gọi là Khai báo sử dụng không gian tên (namespace using declaration), hoặc gọi đơn giản là Khai báo using (using declaration) (Fig. 1-5).

Fig. 1-4 + Toán tử giải quyết phạm vi

Tên_không_gian_tên::Định_danh

  • Có thể chỉ định trực tiếp định danh thuộc về một không gian tên cụ thể

Ví dụ:

				
					namespace N {
    extern double a;
    int foo1();
}
...
int main() {
    N::foo1(); return N::a;
}				
			

Fig. 1-5 + Khai báo sử dụng không gian tên (using declaration) 

using Tên_không_gian_tên::Định_danh;
  • Khai báo định danh của một không gian tên cụ thể (được chỉ định trực tiếp bởi toán tử) vào trong phạm vi đó, để có thể sử dụng chỉ bằng định danh

Ví dụ:

				
					namespace N {
    extern double a;
    int foo1();
}
...
using N::foo1;
int main() {
    foo1(); return N::a;
}				
			

Fig. 1-6 + Chỉ thị sử dụng không gian tên (using directive) 

using namespace Tên_không_gian_tên;
  • Không phải là khai báo, mà là chỉ thị. Trong phạm vi (phần còn lại) nơi chỉ thị này được thực hiện với tên của một không gian tên cụ thể, ta có thể chỉ định trực tiếp các định danh cứ như thể định nghĩa không gian tên đó chưa từng được thực hiện vậy.

Ví dụ:

				
					namespace N {
    extern double a;
    int foo1();
}
...
using namespace N;
int main() { foo1(); return a; }				
			

Phạm vi hiệu lực của bản thân using declaration là từ điểm khai báo này cho đến hết phạm vi nơi khai báo này được thực hiện. Lấy ví dụ hàm foo1() trong List 1-3: 

using N::foo1;

Chỉ cần khai báo sẵn như vậy, thì khi gọi foo1(); sẽ là gọi N::foo1(). Nếu dùng using declaration, ta có thể giảm bớt chút công sức chỉ định không gian tên. Tuy nhiên, trong trường hợp số lượng định danh lớn, ta phải thực hiện using declaration cho rất nhiều định danh, điều này cũng khá vất vả. Vì thế, có một lệnh cho phép sử dụng toàn bộ định danh trong một không gian tên cụ thể. Cái này gọi là Chỉ thị sử dụng không gian tên (namespace using directive), hoặc gọi đơn giản là Chỉ thị using (using directive) (Fig. 1-6). Cú pháp của using directive như sau:

using namespace Tên_không_gian_tên;

Bằng cách sử dụng using directive, tất cả định danh của không gian tên đã chỉ định sẽ có thể sử dụng mà không cần “Tên_không_gian_tên::”. Phạm vi hiệu lực của bản thân lệnh này là từ điểm có lệnh này cho đến hết phạm vi nơi lệnh này được thực hiện. Tuy nhiên, lệnh này tuy tiện lợi nhưng cần chú ý rằng nếu lạm dụng thì sẽ làm mất đi ý nghĩa của việc đưa vào không gian tên. Bằng việc tạo ra mô-đun hay thư viện bên trong một không gian tên cụ thể, ta có thể tạo thư viện mà không lo lắng về sự xung đột với các tên có phạm vi toàn cục khác. Ngoài ra, dù có kết hợp các thư viện do nhiều công ty khác nhau tạo ra, sự xung đột tên cũng sẽ ít xảy ra hơn. Tuy nhiên, cần chú ý rằng nếu sử dụng tên không gian tên quá ngắn, khả năng các thư viện khác có cùng tên không gian sẽ cao lên. Hãy đặt tên không gian tên có độ dài vừa phải và mang ý nghĩa rõ ràng.

Fig. 1-7 + Không gian tên không tên (Unnamed Namespace)

namespace {
    Dãy các khai báo và định nghĩa
}
  • Các định danh được khai báo/định nghĩa trong không gian tên không tên có thể được tham chiếu từ bên trong Đơn vị biên dịch (translation unit / 翻訳単位) có định nghĩa không gian tên không tên này.

  • Không gian tên không tên tồn tại riêng biệt cho mỗi đơn vị biên dịch, và không thể tham chiếu các định danh trong không gian tên không tên này từ bên ngoài đơn vị biên dịch.

 

Ví dụ:

				
					namespace {
    double a = 0.0;
}
int main() { return a; }				
			

Fig. 1-8 + Định nghĩa biệt danh không gian tên

namespace Biệt_danh = Tên_không_gian_tên_đã_định_nghĩa;
  • Có thể đặt một tên khác cho không gian tên

Ví dụ:

				
					namespace MyLibVersion01 { /* Lược bỏ */ }
namespace MyLibVersion02 { /* Lược bỏ */ }
namespace MyLib = MyLibVersion01;				
			

Không gian tên không tên

Khi định nghĩa không gian tên, nếu lược bỏ tên không gian tên, thì đó sẽ là định nghĩa một Không gian tên không tên (unnamed namespace) (Fig. 1-7).
Các định danh được khai báo/định nghĩa trong không gian tên không tên có thể được tham chiếu ngay bằng tên định danh từ trong đơn vị biên dịch có định nghĩa không gian tên không tên đó, nhưng không thể tham chiếu từ đơn vị biên dịch khác. Ngoài ra, không gian tên không tên được định nghĩa trong mỗi đơn vị biên dịch được xử lý như các không gian tên riêng biệt nhau. Vì vậy, không gian tên không tên có thể được dùng để che giấu các định danh khỏi bên ngoài đơn vị biên dịch. Theo nghĩa đó, nó giống với việc khai báo/định nghĩa định danh toàn cục có gắn static.
Thực ra, trong C++, việc sử dụng từ khóa dự phòng static để chỉ ra rằng cái tên đó là cục bộ trong đơn vị biên dịch là không được khuyến khích. Vốn dĩ, không gian tên được đưa vào để làm rõ vai trò của từng định danh toàn cục, nên lý tưởng nhất là tất cả định danh toàn cục đều được khai báo/định nghĩa trong một không gian tên nào đó. Vì vậy, thay vì thực hiện khai báo/định nghĩa định danh toàn cục với static, ta nên sử dụng không gian tên không tên.

Định nghĩa biệt danh không gian tên

Tên của không gian tên có thể được đặt một tên khác (Fig. 1-8). Nếu sử dụng định nghĩa biệt danh này, ta có thể đặt một biệt danh ngắn gọn vừa phải cho tên không gian tên quá dài, và có thể nghĩ đến các ứng dụng như chuyển đổi sử dụng nhiều thư viện có phiên bản khác nhau.

Ví dụ về đoạn mã nêu trong Fig. 1-8 là ví dụ sử dụng thư viện phiên bản cũ (định nghĩa trong không gian tên MyLibVersion01) và thư viện phiên bản mới (định nghĩa trong không gian tên MyLibVersion02) dưới cùng một tên không gian tên MyLib. Khi sử dụng thư viện phiên bản cũ, ta định nghĩa biệt danh là: 

namespace MyLib = MyLibVersion01;

và khi sử dụng phiên bản mới thì chỉ cần đổi thành:

namespace MyLib = MyLibVersion02;

là được.

Định nghĩa định danh của tên không gian tên

Định danh được khai báo trong không gian tên có thể được đưa ra định nghĩa ở bên ngoài khối không gian tên bằng cách sử dụng tên dạng “Tên_không_gian_tên::Tên_định_danh”. Trường hợp thực hiện định nghĩa bên ngoài khối không gian tên, nếu đó không phải là định danh đã được khai báo trong không gian tên tương ứng thì sẽ bị lỗi (Fig. 1-9). Trường hợp thực hiện định nghĩa bên trong khối không gian tên, có thể xảy ra sai sót là lỡ tay định nghĩa những định danh mà mình không định đưa vào không gian tên đó ngay bên trong đó; nhưng nếu chỉ khai báo ở bên trong khối và thực hiện định nghĩa ở bên ngoài khối, thì có thể phòng tránh được những sai lầm như vậy.

Fig. 1-9 + Định nghĩa định danh được khai báo trong không gian tên

  • Định nghĩa có thể thực hiện bên ngoài khối không gian tên bằng cách dùng “Tên_không_gian_tên::Định_danh”

  • Nếu “Tên_không_gian_tên::Định_danh” chưa được khai báo trong không gian tên tương ứng, sẽ bị lỗi và không thể định nghĩa

Ví dụ:

				
					namespace name1 { extern int a; }
int name1::a = 0;
int name1::b = 0; // Sẽ bị lỗi				
			

Fig. 1-10 + Sáp nhập vào không gian tên khác

				
					namespace Tên_không_gian_tên_A {
    using Tên_không_gian_tên_1::Định_danh_1;
    using Tên_không_gian_tên_2::Định_danh_2;
    ...
}				
			
  • Nếu khai báo định danh bằng using declaration bên trong một không gian tên, định danh đó cũng sẽ thuộc về không gian tên đó

Ví dụ:

				
					namespace name1 { int i = 1; }
int j = 10;
namespace name2 {
    using name1::i;
    using ::j;
}
int main() {
    int a = name2::i; // Tham chiếu name1::i
    int b = name2::j; // Tham chiếu ::j
    return 0;
}				
			

Sáp nhập vào không gian tên khác

Một cách sử dụng thú vị của using declaration là sáp nhập một định danh (identifier) cụ thể vào một không gian tên khác (Fig. 1-10). Ví dụ:

				
					namespace name1 { int i = 1; }
int j = 10;
namespace name2 {
    using name1::i;
    using ::j;
}				
			

Như trên, ta sử dụng using declaration bên trong không gian tên. Trong trường hợp này, name1::i và biến toàn cục ::j cũng sẽ thuộc về không gian tên name2. Tức là, name1::i và ::j có thể được tham chiếu tương ứng là: 

name2::i
name2::j

Hợp nhất không gian tên

Như được hiển thị trong Fig. 1-11, nếu sử dụng khéo léo using directive, ta có thể hợp nhất nhiều không gian tên thành một không gian tên. Phương pháp hợp nhất không gian tên rất hữu hiệu trong các trường hợp như: khi chương trình được chia thành các mô-đun nhỏ và mỗi mô-đun được gán một không gian tên riêng biệt, nhưng ta muốn tích hợp chúng vào một thư viện lớn có một không gian tên duy nhất.

Không gian tên std của thư viện chuẩn

Đến đây, chắc bạn đã hiểu dòng thứ 3 của List 1-2:

using namespace std;

đang làm gì rồi. Đây là using directive để sử dụng các định danh của không gian tên std mà không cần trang trí thêm tiền tố “std::”.

 

Fig. 1-11 + Hợp nhất không gian tên

				
					namespace Tên_không_gian_tên_A {
    using namespace Tên_không_gian_tên_1;
    using namespace Tên_không_gian_tên_2;
    ...
}
// Sử dụng using directive, có thể tích hợp nhiều không gian tên vào không gian tên A				
			

Ví dụ:

				
					namespace name1 { extern int a; }
namespace name2 { extern int b; }
namespace nameA {
    using namespace name1;
    using namespace name2;
}
int main() {
    return nameA::a; // nameA::name1::a
}				
			

Vậy, std là gì? Thực ra, tất cả các định danh của thư viện C++ đều được khai báo và định nghĩa trong không gian tên std này. Tức là, cả cout và endl, vốn dĩ nên được viết là:

std::cout

hoặc, 

std::endl

Như đã nói ở trước, việc lạm dụng using directive là không tốt, nhưng nếu cứ phải thêm “std::” vào tất cả định danh của thư viện chuẩn thì số lượng ký tự trên mỗi dòng mã nguồn sẽ trở nên rất nhiều, nên trong sách này, vì lý do không gian trình bày, tôi sẽ tận dụng cách viết using namespace std;.

Tiêu đề C và Tiêu đề C++

Cách xử lý không gian tên của thư viện chuẩn ngôn ngữ C

Tiêu chuẩn C++ được thiết kế để tương thích với ngôn ngữ C nhiều nhất có thể, như đã đề cập trước đó. Như có thể thấy từ việc List 1-1 có thể được biên dịch nguyên trạng, thư viện chuẩn ngôn ngữ C cũng trở thành một phần của thư viện chuẩn C++. Tức là, các tiêu đề chuẩn như stdio.h cũng được chuẩn bị sẵn trong thư viện chuẩn C++.
Tại đây, một vấn đề về không gian tên nảy sinh. Đó là việc tên định danh của thư viện chuẩn ngôn ngữ C có nên đưa vào trong không gian tên std hay không. Dựa trên chính sách mới của C++, tất cả định danh của thư viện chuẩn nên được khai báo và định nghĩa trong không gian tên std. Tuy nhiên, nếu làm vậy, để biên dịch mã nguồn của các chương trình ngôn ngữ C trong quá khứ bằng trình biên dịch C++, ta sẽ phải viết lại đáng kể.
Để giải quyết vấn đề này, thư viện chuẩn C++ cung cấp 2 loại thư viện chuẩn ngôn ngữ C. Một loại cho phép tham chiếu định danh mà không cần chỉ định không gian tên, loại còn lại là loại đã khai báo và định nghĩa tất cả định danh trong không gian tên “std”. Trường hợp sử dụng loại đầu tiên, ta dùng tệp tiêu đề hoàn toàn cùng tên với thư viện chuẩn ngôn ngữ C. Nếu là tiêu đề của mô-đun nhập xuất chuẩn, thì đó là stdio.h quen thuộc. Ngược lại, trường hợp sử dụng loại thứ hai, tiêu đề cần include sẽ là tên tiêu đề chuẩn ngôn ngữ C tương ứng thêm chữ cái thường “c” vào đầu, và bỏ đi phần mở rộng “.h”. Tức là, tiêu đề tương ứng với stdio.h sẽ là:

cstdio

Nhờ công phu này, các chương trình ngôn ngữ C trong quá khứ có thể sử dụng thư viện chuẩn ngôn ngữ C ngay cả dưới hệ thống xử lý C++ mà không cần thay đổi lớn.
Mặt khác, trong lập trình C++, nếu bạn nhất thiết muốn sử dụng thư viện chuẩn ngôn ngữ C, bạn phải include các tiêu đề mới như cstdio và sử dụng các định danh nằm trong không gian tên std như sau: 

std::printf();

Các tiêu đề cũ có phần mở rộng “.h” giống ngôn ngữ C được gọi là “Tiêu đề C” (C header), còn các tiêu đề đặc thù của C++ như iostream hay cstdio được gọi là “Tiêu đề C++” (C++ header).

Cơ chế nhỏ đối với tiêu đề C++

Đối với các nhà sản xuất thực sự tạo ra trình biên dịch, việc tạo ra hoàn toàn riêng biệt hai phiên bản thư viện chuẩn ngôn ngữ C là:

  • Phiên bản không sử dụng không gian tên

  • Phiên bản định nghĩa trong không gian tên std

là tốn công gấp đôi. Vì vậy, có thể nghĩ ra phương pháp đối ứng bằng cách, chẳng hạn như chỉ chuẩn bị phiên bản không dùng không gian tên, sau đó thực hiện một cơ chế nhỏ (trick) vào tiêu đề C++. Cơ chế đó là sử dụng phương pháp đã giới thiệu ở Fig. 1-10 để làm cho nội dung của cstdio trở nên như sau:

				
					#include <stdio.h>
namespace std {
    using ::printf;
    using ::stdin;
    using ::stdout;
    ...
    // (Sau đây, thực hiện using declaration đối với tất cả định danh toàn cục được khai báo trong <stdio.h>)
}				
			

Với cách này, tại thời điểm đọc cstdio, các định danh toàn cục được khai báo trong stdio.h sẽ tự động thuộc về cả không gian tên std nữa.

Tóm tắt

Qua các giải thích trong chương này, tôi nghĩ bạn đã hiểu được rằng hầu hết các chương trình ngôn ngữ C tuân thủ ANSI C đều có thể biên dịch nguyên trạng như là chương trình C++ theo tiêu chuẩn ANSI/ISO. Trên thực tế, C++ vẫn có một số phần nhỏ được thay đổi so với đặc tả của ngôn ngữ C, nhưng tôi sẽ giải thích những điều đó mỗi khi có dịp trong các chương sau. Ngoài ra, trong chương này, chúng ta cũng đã học về luồng xuất của C++, cách hiển thị dữ liệu, và cả về không gian tên. Với những điều này, bạn chắc hẳn đã có thể hiểu được các mẫu chương trình C++ đơn giản. Trong chương tiếp theo, cuối cùng tôi sẽ giới thiệu khái quát về lập trình hướng đối tượng bằng C++.

Leave a Comment

Chia sẻ:

1.1. Điểm khác biệt với ngôn ngữ C

Or copy link

CONTENTS