0.3. Hệ thống/ Bố cục trong ngôn ngữ C

Estimated reading: 51 minutes 236 views

Như là phần cuối cùng của việc ôn tập ngôn ngữ C, tôi sẽ giải thích bức tranh toàn cảnh về hệ thống của ngôn ngữ C. Trước hết, tôi sẽ trình bày một chương trình nguồn có thể chạy thực tế trong List 0-1. Ngoài ra, về tên file của chương trình nguồn ngôn ngữ C, mặc dù trong quy chuẩn ANSI không quy định đặc biệt, nhưng theo truyền thống người ta thường sử dụng phần mở rộng là “.c” ở cuối tên file. Vì vậy, tên file của List 0-1 là “hello.c”.

Chương trình ngôn ngữ C đơn giản nhất

List 0-1 là chương trình theo truyền thống thường được đưa ra làm ví dụ là “chương trình ngôn ngữ C đơn giản nhất” trong các sách giáo khoa về ngôn ngữ C. Khi chạy, nó sẽ hiển thị:

Hello World!

List 0-1 + Chương trình hiển thị “Hello World!”

Khai báo tham chiếu bên ngoài (External Reference Declaration / 外部参照宣言)

Cho đến giờ là câu chuyện về liên kết ngoài của “bên được tham chiếu”. Mặt khác, đối với “bên đi tham chiếu”, để sử dụng tên hàm hoặc tên biến bên ngoài chưa được định nghĩa/khai báo trong file nguồn hiện tại, ta phải truyền đạt rõ ràng cho trình biên dịch biết kiểu của đối tượng tham chiếu bên ngoài đó. Phương pháp để làm việc đó là Khai báo tham chiếu bên ngoài. Khai báo tham chiếu bên ngoài có dạng:

Kiểu_trả_về Tên_hàm(Danh_sách_tham_số_giả);
extern Tên_kiểu Tên_biến_đại_cục;

Cái trước là khai báo quy định hoàn toàn kiểu của hàm, được gọi là Khai báo nguyên mẫu hàm (function prototype declaration). Điều phải cẩn thận là cái sau: khai báo tham chiếu bên ngoài của biến đại cục. Nếu bạn quên khởi tạo khi tuyên bố một biến đại cục, nó sẽ trở thành hình thức hoàn toàn giống với khai báo tham chiếu bên ngoài. Khi đó, việc thực thể được tuyên bố ở đâu sẽ trở nên mơ hồ. Vì vậy, biến đại cục bắt buộc phải được tuyên bố kèm theo khởi tạo tại duy nhất một nơi nào đó để trao cho nó một thực thể. Nói chung, người ta gọi việc tuyên bố biến đại cục có kèm khởi tạo là “Định nghĩa biến đại cục” (Definition) để phân biệt rõ ràng với khai báo tham chiếu bên ngoài.

Quy trình giải quyết tham chiếu bên ngoài

Chúng ta hãy cùng xem quy trình cho đến khi các tham chiếu bên ngoài được giải quyết. Đầu tiên, trong quá trình biên dịch, nếu trình biên dịch tìm thấy tên biến chưa được khai báo hoặc tên hàm chưa được định nghĩa, nó sẽ coi đó là đối tượng của tham chiếu bên ngoài. Khi đó, nếu có khai báo tham chiếu bên ngoài, nó sẽ dựa vào đó để biên dịch phần đang tham chiếu và giao việc kết nối với thực thể (entity) cho Linker (ví dụ: “Module tham chiếu B” trong Fig. 0-36). Nếu không có khai báo tham chiếu bên ngoài, nó sẽ phỏng đoán kiểu một cách tùy ý rồi biên dịch. Nếu việc phỏng đoán này không khớp với kiểu thực tế, nó sẽ gây ra lỗi trong quá trình liên kết (lỗi link / link error) hoặc khiến chương trình hoạt động sai lệch (Chú ý rằng việc Compiler tự ý phỏng đoán Biến/Hàm khi chưa có khai báo là tiêu chuẩn ANSI C/C89 cũ. Với các chuẩn C hiện đại như C99, C11, C17,… sẽ báo lỗi ngay). Sau đó, Linker sẽ kiểm tra thông tin liên kết mà mỗi module nắm giữ và kết nối bên tham chiếu với thực thể được tham chiếu.

Tạo Header file và Module

Chúng ta nên viết gì vào Header file? Đầu tiên, một phần của các kiểu dữ liệu, hằng số liệt kê, định nghĩa macro được định nghĩa trong module cũng sẽ cần thiết cho cả bên sử dụng module đó, nên cần phải đưa chúng vào. Ngoài ra, việc bên sử dụng module phải tự mình viết tất cả các khai báo tham chiếu bên ngoài từng cái một là rất vất vả. Do đó, nếu ta viết sẵn tất cả các khai báo tham chiếu bên ngoài của các biến đại cục và hàm mà module đó công khai ra bên ngoài vào trong Header file của module, thì bên sử dụng module chỉ cần đọc Header file đó vào là có thể sử dụng module đó một cách bình thường. Và, bản thân module đó chắc cũng sử dụng các module khác, nên nó sẽ include các Header file của các module kia. Tóm lại, nội dung của Header file module sẽ bao gồm:

(1) Các lệnh include nhóm Header file của các module khác (2) Định nghĩa của các kiểu, hằng số liệt kê, macro được phép công khai (3) Khai báo tham chiếu bên ngoài của các biến đại cục, hàm được phép công khai

Như vậy, vì trong Header file của module tập hợp các thông tin cần thiết để sử dụng module, nên Header file đôi khi được gọi là “Giao diện của module” (module interface).

File nguồn module và File header module

Bây giờ, nếu nhìn kỹ các yếu tố của Header file, ta thấy (1) và (2) cũng cần thiết cho cả file nguồn của module. Tuy nhiên, việc viết cùng một nội dung vào cả Header file và file nguồn rất phiền phức, và cũng khó để giữ cho chúng luôn khớp nhau mà không có mâu thuẫn. Vì thế, giải pháp là include chính Header file module vào trong file nguồn module để tận dụng nội dung của nó. Mục (3) là khai báo tham chiếu bên ngoài, dù có được include vào file nguồn module cũng không có vấn đề gì. Trường hợp khai báo tham chiếu bên ngoài của biến đại cục và định nghĩa có kèm khởi tạo cùng tồn tại trong một đơn vị dịch, thì phía khai báo tham chiếu bên ngoài sẽ bị bỏ qua.

Ngoài ra, các khai báo nguyên mẫu hàm tốt hơn hết là nên được đọc vào file nguồn. Lý do là vì, như đã nói ở trên, khi trình biên dịch gặp một hàm chưa được định nghĩa, nếu không có nguyên mẫu hàm, nó sẽ dự đoán kiểu trả về và kiểu tham số một cách tùy ý rồi biên dịch phần gọi hàm đó. Trong các trường hợp như các hàm gọi lẫn nhau, không phải lúc nào ta cũng có thể thực hiện định nghĩa hàm trước khi gọi hàm. Tuy nhiên, nếu nguyên mẫu hàm được đọc vào ở đầu file nguồn như một phần của Header file, thì bất kể thứ tự định nghĩa hàm như thế nào, việc gọi hàm vẫn có thể được biên dịch chính xác.

Mặt khác, file nguồn của module sẽ thực hiện các việc như: include các Header file chứa nội dung không muốn công khai cho người dùng module (thường gọi là “Private header file”), định nghĩa các kiểu, hằng số liệt kê, macro không muốn công khai, khai báo nguyên mẫu của các hàm không muốn công khai, định nghĩa có gắn static cho các hàm/biến đại cục không công khai, và định nghĩa không gắn static cho các hàm/biến đại cục công khai. Về khái quát của file nguồn module và file header module, tôi đã tóm tắt trong Fig. 0-37, hãy tham khảo nó.

				
					1: #include <stdio.h>
2:
3: int main( void )
4: {
5:    printf("Hello World!\n");
6:
7:    return 0;
8: }				
			

Chúng ta hãy cùng xem từ dòng đầu tiên. Dòng 1 là:

#include <stdio.h>

Dòng bắt đầu bằng dấu # này được gọi là lệnh tiền xử lý (preprocessor directive / プリプロセッサ命令). Chương trình nguồn ngôn ngữ C sẽ được trình biên dịch (compiler / コンパイラ) chuyển đổi thành ngôn ngữ máy (machine language / 機械語) mà CPU có thể thực thi (còn gọi là “được biên dịch” hay “được dịch”). Thực ra, trước khi thực hiện công việc biên dịch, chương trình nguồn sẽ được gia công bởi một chương trình (hoặc một chức năng tích hợp trong trình biên dịch) gọi là bộ tiền xử lý (preprocessor / プリプロセッサ). Trình biên dịch sẽ biên dịch chương trình nguồn đã được gia công bởi bộ tiền xử lý (được gọi là đơn vị dịch / translation unit / 翻訳単位). Và thứ đưa ra chỉ thị gia công cho “người thợ gia công” – bộ tiền xử lý chính là lệnh tiền xử lý.

Lệnh tiền xử lý (Preprocessor directive / プリプロセッサ命令)

Dạng tổng quát của lệnh tiền xử lý

Tại đây, tôi sẽ tóm tắt về các lệnh tiền xử lý chính. Dạng tổng quát của lệnh tiền xử lý như bên dưới. Về cơ bản, mỗi dòng là một lệnh tiền xử lý. Dòng có chứa mô tả tiền xử lý được gọi là dòng tiền xử lý.

Ngoài ra, trong trường hợp bạn nhất định muốn viết một lệnh tiền xử lý trải dài trên nhiều dòng, bằng cách thêm dấu \ vào cuối dòng, bạn có thể làm cho nó được xử lý như một dòng logic duy nhất. Ví dụ, dòng đầu tiên của List 0-1 cũng có thể được viết là: 

#include \
<stdio.h>

Lệnh Include (Include Directive / インクルード命令)

Đầu tiên là lệnh #include (Lệnh Include) đã xuất hiện trong List 0-1. Bộ tiền xử lý sẽ xóa dòng tiền xử lý và thay vào đó sẽ đọc tệp được chỉ định vào phần đó. Việc này được gọi là “include” (bao gồm). Nếu trong phần được đọc vào có chứa lệnh tiền xử lý, bộ tiền xử lý sẽ tiếp tục thực hiện gia công thêm.

Nếu bao quanh tên tệp bằng dấu <>, nó sẽ tìm và đọc tệp đối tượng từ nhóm tệp được trang bị tiêu chuẩn trong ngôn ngữ C. Nếu bao quanh bằng dấu "", nó sẽ tìm và đọc tệp đối tượng từ nhóm tệp nằm ở vị trí khác với nơi đặt các tệp tiêu chuẩn (ví dụ: cùng vị trí với tệp nguồn đang được biên dịch).

Các loại tệp Include (Types of include files / インクルードファイルの種類)

(1) Include tệp header chuẩn (Standard header file / 標準ヘッダファイル)

#include <Tên_tệp_header_chuẩn>
  • Bao quanh tên tệp bằng dấu <>

Ví dụ:

#include <stdio.h>

(2) Include tệp header ngoài chuẩn (Non-standard header file / 標準外ヘッダファイル)

#include "Tên_tệp_header_ngoài_chuẩn"
  • Bao quanh tên tệp bằng dấu ""

Ví dụ:

#include “mymodule.h”

Định nghĩa Macro đơn giản (Simple Macro Definition / 単純マクロ定義)

Bằng cách định nghĩa macro đơn giản, ta có thể đặt tên (tên macro) cho một chuỗi các ký hiệu (Fig. 0-31). Khi bộ tiền xử lý tìm thấy tên macro đã được định nghĩa, nó sẽ thay thế tên đó bằng chuỗi ký hiệu gốc (gọi là “khai triển macro” hay “macro expansion”). Ví dụ, nếu ta định nghĩa số PI $\pi$ là:

 
#define PI (3.14159265)

thì trong chương trình, tại những nơi muốn sử dụng số PI, bằng cách viết PI, nó sẽ được khai triển thành hằng số (3.14159265).

Các hằng số được định nghĩa bằng định nghĩa macro như vậy được gọi là hằng số macro. Theo truyền thống, tên macro thường được định nghĩa bằng chữ in hoa toàn bộ để chỉ rõ đó là tên macro.

Trong ngôn ngữ C, có một vài hằng số macro đã được định nghĩa sẵn. Ví dụ, __LINE__ và __FILE__ sẽ lần lượt được khai triển macro thành chuỗi ký tự biểu thị số dòng trong chương trình nguồn và tên tệp nguồn.

Ngoài ra, vì các tên macro bắt đầu bằng 2 dấu gạch dưới được quy định là dùng cho các hằng số macro đã định nghĩa sẵn hoặc tên macro mà trình biên dịch sử dụng nội bộ, nên lập trình viên không được định nghĩa các tên macro như vậy. Hơn nữa, khi định nghĩa macro, ta có thể lược bỏ phần chuỗi ký hiệu và chỉ định nghĩa tên thôi cũng được.

+++++ Fig. 0-31  Định nghĩa Macro đơn giản +++++

#define Tên_macro Chuỗi_ký_hiệu
#define Tên_macro

Ví dụ:

#define PI (3.14159265)
#define MY_NAME "Ourshare Value"
#define VERSION (0x0010)
#define TEST 

+++++ Kết thúc Fig. 0-31 +++++

Định nghĩa Macro có tham số (Macro Definition with Arguments / 引数付きマクロ定義)

Định nghĩa macro có tham số cho phép định nghĩa macro đi kèm với tham số như trong Fig. 0-32. Ví dụ, nếu định nghĩa: 

#define ADD( a, b ) ((a)+(b))

thì macro:

ADD( 1, 2 )

sẽ được khai triển thành chuỗi ký hiệu:

((1) + (2))

Tức là, nhờ định nghĩa macro có tham số, ta có thể mô tả một loạt các xử lý giống như một hàm. Cái ADD này chính là một macro thực hiện phép cộng. Ngoài ra, mỗi tham số thực có thể được cấu thành từ nhiều ký hiệu, trường hợp viết là:

ADD( 2 * x, 3 )

thì nó sẽ được khai triển thành:

((2 * x) + (3))

Lưu ý rằng, trong ví dụ ADD, việc bao quanh toàn bộ chuỗi ký hiệu được định nghĩa macro và các tham số giả bên trong nó bằng dấu () là để ngăn chặn việc các tham số thực bị kết hợp theo cách không mong muốn bên trong biểu thức được khai triển.

+++++ Fig. 0-32 + Định nghĩa Macro có tham số +++++

#define Tên_macro( Tham_số_giả_1, ..., Tham_số_giả_n ) Chuỗi_ký_hiệu
  • Không được để khoảng cách giữa Tên_macro và dấu (.

  • Chuỗi ký hiệu này có thể được mô tả bằng cách sử dụng các tham số giả.

Ví dụ: 

#define AVERAGE( a, b ) (((a) + (b))/2)
#define SWAP_NUMBERS( a, b ) { long double c; c = a; a = b; b = c; }

+++++ Kết thúc Fig. 0-32 +++++

Ví dụ, nếu định nghĩa macro ADD mà không sử dụng () như sau:

#define ADD( a, b ) a + b

thì trong trường hợp định viết biểu thức “Cộng y vào (x * 2), rồi nhân tổng đó với z” như sau:

ADD( x * 2, y ) * z

nó sẽ bị khai triển thành:

x * 2 + y * z

và kết quả thu được sẽ không như ý muốn (do thứ tự ưu tiên của phép toán).

Hủy định nghĩa Macro (Undefining Macros / マクロ定義の解除)

Macro có thể bị hủy định nghĩa bằng lệnh #undef. Ví dụ: 

#undef ADD

làm như vậy có thể hủy bỏ macro ADD.

Nhập văn bản có điều kiện (Conditional Text Inclusion / 条件付きテキスト取り込み)

Sử dụng chức năng nhập văn bản có điều kiện, ta có thể làm cho một phần của chương trình nguồn trở nên có hiệu lực hoặc vô hiệu chỉ khi một điều kiện nào đó được thỏa mãn (Fig. 0-33). Trong biểu thức điều kiện, ta có thể sử dụng các toán tử quan hệ, toán tử đẳng thức, toán tử bất đẳng thức, toán tử logic… của ngôn ngữ C. Ngoài ra, trong biểu thức điều kiện, ta có thể dùng:

defined(Tên_macro)

để kiểm tra xem tên macro đó đã được định nghĩa hay chưa. Lệnh này trả về 1 nếu tên macro đã được định nghĩa, và trả về 0 nếu chưa định nghĩa. Hơn nữa, như là dạng rút gọn của:

#if defined(Tên_macro)
#if !defined(Tên_macro)

ta có thể sử dụng lần lượt các hình thức sau:

#ifdef Tên_macro
#ifndef Tên_macro

Bằng cách sử dụng nhập văn bản có điều kiện, với cú pháp: 

#if 0
... Phần muốn biến thành comment ...
#endif

ta có thể biến một phạm vi rộng lớn của chương trình nguồn thành comment.

+++++ Fig. 0-33 + Nhập văn bản có điều kiện +++++

Sơ đồ cấu trúc #if, #else, #elif, #endif

※ Biểu thức điều kiện là biểu thức số nguyên kiểu long hoặc unsigned long.

※ Khi biểu thức điều kiện A là Đúng, Phạm vi 1 sẽ có hiệu lực; khi là Sai, Phạm vi 2 sẽ có hiệu lực.

※ Chỉ khi biểu thức điều kiện A là Sai VÀ biểu thức điều kiện B là Sai, thì Phạm vi 3 mới có hiệu lực.

※ Có thể dùng defined(Tên) trong biểu thức điều kiện để kiểm tra xem một tên cụ thể có được định nghĩa là macro hay chưa. 

				
					// Ví dụ 1:
#define DEBUG 1
...
#if DEBUG
/* Code dùng cho debug */
#endif

// Ví dụ 2:
#if defined( __STDC__ )
/* Code dùng cho ANSI C */
#else
/* Code dùng cho phi ANSI C */
#endif				
			

+++++ Kết thúc Fig. 0-33 +++++

Ngoài ra, như ví dụ 1 trong Fig. 0-33, ta có thể đưa code dùng cho debug vào tạm thời, hoặc như ví dụ 2, ta có thể viết song song cả cách viết tuân thủ chuẩn ANSI và cách viết không tuân thủ (__STDC__ là macro được định nghĩa trong các trình biên dịch C tuân thủ chuẩn ANSI). Tương tự, nó cũng có thể được dùng để viết phân tách các phần phụ thuộc vào hệ điều hành.

Module và Header file

Vai trò của Module và Header file

Trong List 0-1 có include tệp tên là stdio.h, đây là tệp cần thiết để sử dụng các nhóm hàm hoặc kiểu dữ liệu thực hiện việc hiển thị ký tự lên màn hình hay nhập xuất dữ liệu ra tệp. Trong trình biên dịch C tuân thủ chuẩn ANSI, có rất nhiều hàm chuẩn được quy định bởi tiêu chuẩn được chuẩn bị sẵn để có thể sử dụng. Nhóm hàm này được gọi là Thư viện chuẩn (standard library). Các hàm của thư viện chuẩn được tập hợp lại thành nhóm gồm những hàm có liên quan với nhau. Tập hợp các hàm có liên quan với nhau này được gọi là Module (module) (module này cũng thu được bằng cách biên dịch chương trình nguồn). Và, đối với mỗi module, có sẵn một tệp mô tả các thông tin cần thiết để sử dụng module đó. Tệp này nói chung được gọi là Header file (header file / ヘッダファイル) (Fig. 0-34). Tên header file của ngôn ngữ C theo truyền thống thường gắn phần mở rộng là “.h”. stdio.h được include trong List 0-1 chính là header file của module dùng cho nhập xuất dữ liệu chuẩn (stdio là viết tắt của STanDard Input Output).

+++++ Fig. 0-34 +++++

Trong List 0-1, bên trong hàm main() đang gọi hàm tên là printf(). Hàm printf() là một thành viên của thư viện nhập xuất chuẩn, vì thế cần thiết phải include stdio.h. Khi chạy List 0-1, hàm main() gọi hàm printf(), và hàm printf() sẽ xuất chuỗi ký tự “HelloWorld!\n” được truyền vào tham số thực ra màn hình. Ký tự “\n” ở cuối trông có vẻ như không được xuất ra, nhưng đây là chỉ định ký tự đặc biệt biểu thị việc xuống dòng. Trên màn hình, việc xuống dòng được thực hiện ngay sau chuỗi ký tự “Hello World!”. Hàm printf() là hàm tiện lợi có thể chuyển đổi dữ liệu thành chuỗi ký tự và xuất ra theo nhiều định dạng chỉ định khác nhau (như căn trái hay hiển thị bao nhiêu chữ số).

Hệ thống ngôn ngữ C (C Language System / C言語のシステム)

Toàn cảnh hệ thống về việc chương trình ngôn ngữ C được xử lý như thế nào để trở thành chương trình có thể thực thi được thể hiện trong Fig. 0-35. Đầu tiên, chương trình nguồn được xử lý bởi bộ tiền xử lý (thực hiện đọc các tệp include, khai triển macro…). Sau đó, nó được dịch sang ngôn ngữ máy bởi trình biên dịch và trở thành module. Vì tệp nguồn hiển thị trong Fig. 0-35 có chứa hàm main(), nên nó có thể trở thành chương trình thực thi được. Một chương trình gọi là Linker (bộ liên kết) sẽ kiểm tra các biến và hàm đang tham chiếu lẫn nhau trong các module, và kết hợp các module cần thiết lại. Và thế là, chương trình có thể thực thi được hoàn thành. Vậy thì, trong header file mô tả cái gì? Ngoài ra, làm thế nào để tạo module và header file tự chế? Để trả lời những câu hỏi này, ta phải biết được linker giải quyết các tham chiếu giữa các module như thế nào.

+++++ Fig. 0-35 +++++

Tham chiếu bên ngoài (External Reference / 外部参照)

Như đã nói ở trước, tên kiểu, tên biến, tên hằng số const được khai báo, định nghĩa bên trong hàm chỉ có hiệu lực bên trong hàm đó, và scope (phạm vi) của chúng có thể nói là “cục bộ hàm” (function local). Mặt khác, tên kiểu, tên hằng số liệt kê, tên biến, tên hằng số const, và bản thân tên hàm được định nghĩa bên ngoài hàm thì được gọi là có scope Đại cục (Toàn cục / global / グローバル). Tại thời điểm biên dịch, các định danh này có scope từ vị trí được khai báo đến cuối đơn vị dịch đó. Cái này được gọi là scope tệp (file scope). Trong số các định danh đại cục này, những tên có “thực thể” (entity), tức là hàm và biến, hằng số const, có thể được tham chiếu từ tệp nguồn khác (chính xác là đơn vị dịch khác). Điều này là do khi biên dịch, linker tạo ra thông tin để có thể tham chiếu những tên này từ bên ngoài. Việc có thể tham chiếu từ bên ngoài đơn vị dịch như thế này được gọi là “Liên kết ngoài (External Linkage / 外部リンケージ)”. Thực ra, để chỉ rõ liên kết ngoài, ta sử dụng từ khóa dự trữ extern:

extern Kiểu_trả_về Tên_hàm(Danh_sách_tham_số_giả) { ... }
extern Tên_kiểu Tên_biến_đại_cục = Giá_trị_khởi_tạo;

nhưng vì extern có thể lược bỏ, nên nếu không gắn extern thì nó sẽ tự động có liên kết ngoài. Ngược lại, cũng có khi ta muốn giấu tên hàm đại cục, tên biến, tên hằng số const đi để không thể tham chiếu từ bên ngoài đơn vị dịch, trong trường hợp đó, cần phải gắn từ khóa dự trữ static một cách rõ ràng để khai báo những tên không muốn công khai (ví dụ: hàm f() và biến đại cục a của Module A trong Fig. 0-36).

 

Khai báo tham chiếu bên ngoài (External Reference Declaration / 外部参照宣言)

Cho đến giờ là câu chuyện về liên kết ngoài của “bên được tham chiếu”. Mặt khác, đối với “bên đi tham chiếu”, để sử dụng tên hàm hoặc tên biến bên ngoài chưa được định nghĩa/khai báo trong file nguồn hiện tại, ta phải truyền đạt rõ ràng cho trình biên dịch biết kiểu của đối tượng tham chiếu bên ngoài đó. Phương pháp để làm việc đó là Khai báo tham chiếu bên ngoài. Khai báo tham chiếu bên ngoài có dạng:

Kiểu_trả_về Tên_hàm(Danh_sách_tham_số_giả);
extern Tên_kiểu Tên_biến_đại_cục;

Cái trước là khai báo quy định hoàn toàn kiểu của hàm, được gọi là Khai báo nguyên mẫu hàm (function prototype declaration). Điều phải cẩn thận là cái sau: khai báo tham chiếu bên ngoài của biến đại cục. Nếu bạn quên khởi tạo khi tuyên bố một biến đại cục, nó sẽ trở thành hình thức hoàn toàn giống với khai báo tham chiếu bên ngoài. Khi đó, việc thực thể được tuyên bố ở đâu sẽ trở nên mơ hồ. Vì vậy, biến đại cục bắt buộc phải được tuyên bố kèm theo khởi tạo tại duy nhất một nơi nào đó để trao cho nó một thực thể. Nói chung, người ta gọi việc tuyên bố biến đại cục có kèm khởi tạo là “Định nghĩa biến đại cục” (Definition) để phân biệt rõ ràng với khai báo tham chiếu bên ngoài.

Quy trình giải quyết tham chiếu bên ngoài

Chúng ta hãy cùng xem quy trình cho đến khi các tham chiếu bên ngoài được giải quyết. Đầu tiên, trong quá trình biên dịch, nếu trình biên dịch tìm thấy tên biến chưa được khai báo hoặc tên hàm chưa được định nghĩa, nó sẽ coi đó là đối tượng của tham chiếu bên ngoài. Khi đó, nếu có khai báo tham chiếu bên ngoài, nó sẽ dựa vào đó để biên dịch phần đang tham chiếu và giao việc kết nối với thực thể (entity) cho Linker (ví dụ: “Module tham chiếu B” trong Fig. 0-36). Nếu không có khai báo tham chiếu bên ngoài, nó sẽ phỏng đoán kiểu một cách tùy ý rồi biên dịch. Nếu việc phỏng đoán này không khớp với kiểu thực tế, nó sẽ gây ra lỗi trong quá trình liên kết (lỗi link / link error) hoặc khiến chương trình hoạt động sai lệch (Chú ý rằng việc Compiler tự ý phỏng đoán Biến/Hàm khi chưa có khai báo là tiêu chuẩn ANSI C/C89 cũ. Với các chuẩn C hiện đại như C99, C11, C17,… sẽ báo lỗi ngay). Sau đó, Linker sẽ kiểm tra thông tin liên kết mà mỗi module nắm giữ và kết nối bên tham chiếu với thực thể được tham chiếu.

Tạo Header file và Module

Chúng ta nên viết gì vào Header file? Đầu tiên, một phần của các kiểu dữ liệu, hằng số liệt kê, định nghĩa macro được định nghĩa trong module cũng sẽ cần thiết cho cả bên sử dụng module đó, nên cần phải đưa chúng vào. Ngoài ra, việc bên sử dụng module phải tự mình viết tất cả các khai báo tham chiếu bên ngoài từng cái một là rất vất vả. Do đó, nếu ta viết sẵn tất cả các khai báo tham chiếu bên ngoài của các biến đại cục và hàm mà module đó công khai ra bên ngoài vào trong Header file của module, thì bên sử dụng module chỉ cần đọc Header file đó vào là có thể sử dụng module đó một cách bình thường. Và, bản thân module đó chắc cũng sử dụng các module khác, nên nó sẽ include các Header file của các module kia. Tóm lại, nội dung của Header file module sẽ bao gồm:

(1) Các lệnh include nhóm Header file của các module khác 

(2) Định nghĩa của các kiểu, hằng số liệt kê, macro được phép công khai 

(3) Khai báo tham chiếu bên ngoài của các biến đại cục; khai báo hàm được phép công khai

Như vậy, vì trong Header file của module tập hợp các thông tin cần thiết để sử dụng module, nên Header file đôi khi được gọi là “Giao diện của module” (module interface).

File nguồn module và File header module

Bây giờ, nếu nhìn kỹ các yếu tố của Header file, ta thấy (1) và (2) cũng cần thiết cho cả file nguồn của module. Tuy nhiên, việc viết cùng một nội dung vào cả Header file và file nguồn rất phiền phức, và cũng khó để giữ cho chúng luôn khớp nhau mà không có mâu thuẫn. Vì thế, giải pháp là include chính Header file module vào trong file nguồn module để tận dụng nội dung của nó. Mục (3) là khai báo tham chiếu bên ngoài, dù có được include vào file nguồn module cũng không có vấn đề gì. Trường hợp khai báo tham chiếu bên ngoài của biến đại cục và định nghĩa có kèm khởi tạo cùng tồn tại trong một đơn vị dịch, thì phía khai báo tham chiếu bên ngoài sẽ bị bỏ qua.

Ngoài ra, các khai báo nguyên mẫu hàm tốt hơn hết là nên được đọc vào file nguồn. Lý do là vì, như đã nói ở trên, khi trình biên dịch gặp một hàm chưa được định nghĩa, nếu không có nguyên mẫu hàm, nó sẽ dự đoán kiểu trả về và kiểu tham số một cách tùy ý rồi biên dịch phần gọi hàm đó (Chú ý rằng việc Compiler tự ý phỏng đoán Biến/Hàm khi chưa có khai báo là tiêu chuẩn ANSI C/C89 cũ. Với các chuẩn C hiện đại như C99, C11, C17,… sẽ báo lỗi ngay). Trong các trường hợp như các hàm gọi lẫn nhau, không phải lúc nào ta cũng có thể thực hiện định nghĩa hàm trước khi gọi hàm. Tuy nhiên, nếu nguyên mẫu hàm được đọc vào ở đầu file nguồn như một phần của Header file, thì bất kể thứ tự định nghĩa hàm như thế nào, việc gọi hàm vẫn có thể được biên dịch chính xác.

Mặt khác, file nguồn của module sẽ thực hiện các việc như: include các Header file chứa nội dung không muốn công khai cho người dùng module (thường gọi là “Private header file”), định nghĩa các kiểu, hằng số liệt kê, macro không muốn công khai, khai báo nguyên mẫu của các hàm không muốn công khai, định nghĩa có gắn static cho các hàm/biến đại cục không công khai, và định nghĩa không gắn static cho các hàm/biến đại cục công khai. Về khái quát của file nguồn module và file header module, tôi đã tóm tắt trong Fig. 0-37, hãy tham khảo nó.

Một điểm cần chú ý là tại sao toàn bộ phần không công khai ((2), (3), (4) không cho vào tệp (1) Header file riêng tư mà lại được tách biệt ra? Đó là vì 3 nguyên nhân chính sau:

  •  Trong cùng module A có thể chia làm nhiều file như core.c, utils.c, network.c. Các file này có thể dùng chung file một kiểu dữ liệu hay cấu trúc được định nghĩa ở file Header riêng tư. Tuy vậy mỗi một file lại có những dữ liệu (VD hàm) chỉ dùng cho file của mình, như vậy không cần thiết phải đưa vào file Header riêng tư gây ô nhiễm không gian tên của file nội bộ khác.
  • Khi bạn chỉ cần một phần nhỏ trong file Header riêng nhằm phục vụ cho  core.c chẳng hạn thì tất cả các file nội bộ khác có sử dụng file Header riêng cũng phải biên dịch lại từ đầu. Ngược lại nếu chỉ định nghĩa riêng tư trong thì chỉ mình file core.c phải biên dịch lại.
  • Dễ đọc code hơn

Ví dụ tạo Module (Module Creation Example / モジュールの作例)

Hãy cùng xem một ví dụ đơn giản về tạo . Đề tài là module hóa các phép toán liên quan đến vector 2 chiều, và tạo tệp tiêu đề (header file) cùng tệp nguồn (source file) cho nó. List 0-2 (vector2d.h) là tệp tiêu đề, List 0-3 (vector2d.c) là tệp nguồn.

List 0-2 + Header module tính toán vector 2 chiều (vector2d.h)

				
					1: /***
2:  *** vector2.h
3:  *** - Tệp tiêu đề module tính toán vector 2 chiều -
4:  ***/
5:
6: #if !defined( _VECTOR2D_H_ )
7: #define _VECTOR2D_H_
8:
9: /* Định nghĩa kiểu vector 2 chiều */
10: typedef struct vector2d {
11:     double x, y;
12: } vector2d;
13:
14: /* Khai báo tham chiếu bên ngoài */
15: extern const vector2d gXUnitVector; /* vector đơn vị hướng x */
16: extern const vector2d gYUnitVector; /* vector đơn vị hướng y */
17:
18: /* Nguyên mẫu hàm */
19: vector2d vAdd( vector2d a, vector2d b );         /* Phép cộng */
20: vector2d vSub( vector2d a, vector2d b );         /* Phép trừ */
21: vector2d vMul( double d, vector2d a );           /* Phép nhân */
22: double innerProduct( vector2d a, vector2d b );   /* Tích vô hướng */
23: double norm( vector2d a );                       /* Độ lớn của vector */
24:
25: #endif /* _VECTOR2D_H_ */				
			

List 0-3 + Source module tính toán vector 2 chiều (vector2d.c)

				
					1: /***
2:  *** vector2.c
3:  *** - Tệp nguồn module tính toán vector 2 chiều -
4:  ***/
5: #include <math.h>
6: #include "vector2d.h"
7:
8: /* Định nghĩa vector đơn vị (Hằng số const toàn cục) */
9: const vector2d gXUnitVector = { 1, 0 };
10: const vector2d gYUnitVector = { 0, 1 };
11:
12: /* Định nghĩa không có static cho các hàm công khai */
13: vector2d vAdd( vector2d a, vector2d b ) /* Phép cộng */
14: {
15:     vector2d v;
16:     v.x = a.x + b.x; v.y = a.y + b.y;
17:     return v;
18: }
19:
20: vector2d vSub( vector2d a, vector2d b ) /* Phép trừ */
21: {
22:     vector2d v;
23:     v.x = a.x - b.x; v.y = a.y - b.y;
24:     return v;
25: }
26:
27: vector2d vMul( double d, vector2d a )   /* Phép nhân */
28: {
29:     vector2d v;
30:     v.x = d * a.x; v.y = d * a.y;
31:     return v;
32: }
33:
34: double innerProduct( vector2d a, vector2d b ) /* Tích vô hướng */
35: {
36:     return (a.x * b.x) + (a.y * b.y);
37: }
38:
39: double norm( vector2d a ) /* Độ lớn của vector */
40: {
41:     return sqrt( innerProduct( a, a ) );
42: }				
			

Trong List 0-2, tệp tiêu đề có dạng như sau:

				
					#if !defined( _VECTOR2D_H_ )
#define _VECTOR2D_H_
... (Lược bỏ phần giữa) ...
#endif				
			

Đây là biện pháp để ngăn chặn việc đọc tệp tiêu đề hai lần (double inclusion), một kỹ thuật thường được gọi là Bảo vệ đính kèm (Include Guard / インクルード・ガード). Khi tệp tiêu đề này được đọc lần đầu tiên trong một đơn vị dịch (translation unit), toàn bộ tệp tiêu đề sẽ có hiệu lực, nhưng từ lần thứ hai trở đi, vì macro _VECTOR2D_H_ đã được định nghĩa, nên nội dung của tệp tiêu đề sẽ bị bỏ qua.

Trong tệp tiêu đề này, có mô tả định nghĩa của Kiểu cấu trúc (structure type / 構造体型) vector2d biểu diễn vector 2 chiều, khai báo tham chiếu bên ngoài của vector đơn vị (hằng số const toàn cục), và khai báo nguyên mẫu hàm của các hàm thực hiện phép toán vector.

Trong List 0-3 là tệp nguồn module, ở phần đầu đã đọc math.h (tệp tiêu đề của thư viện toán học chuẩn) và tệp tiêu đề module vector2d.h, sau đó thực hiện định nghĩa vector đơn vị (hằng số const toàn cục) và định nghĩa các hàm tính toán khác nhau.

Nhân tiện, các hằng số toàn cục (global constants) gXUnitVector và gYUnitVector biểu diễn vector đơn vị theo hướng $x$ và hướng $y$ có chữ g thường ở đầu. Điều này là để chỉ rõ rằng đó là biến toàn cục bằng cách thêm chữ cái đầu g của “global” vào tên biến toàn cục.

Hàm sqrt() được sử dụng bên trong hàm norm() là một hàm của Thư viện toán học chuẩn (standard math library / 標準数学ライブラリ) dùng để tìm căn bậc hai. Khi sử dụng module tính toán vector, ta sẽ đọc vector2d.h và sử dụng các chức năng đó.

Ví dụ sử dụng module (Example of Using Module / モジュールの利用例)

Cuối cùng, tôi xin giới thiệu một ví dụ chương trình sử dụng module tính toán vector. Hãy xem List 0-4.

List 0-4 + Ví dụ sử dụng module tính toán vector 2 chiều (usevector2d.c)

				
					1: /***
2:  *** usevector2.c
3:  *** - Ví dụ sử dụng module tính toán vector 2 chiều -
4:  ***/
5: #include <stdio.h>       /* Đọc module nhập xuất chuẩn */
6: #include "vector2d.h"    /* Đọc header module tính toán vector 2 chiều */
7:
8: void printVector2d( vector2d v ) /* Hiển thị nội dung vector 2 chiều */
9: {
10:     printf( "x is %g, y is %g\n", v.x, v.y );
11: }
12:
13: int main( void ) {
14:
15:     vector2d v = { 0.0, 0.0 };   /* Khai báo biến v kiểu vector 2 chiều */
16:
17:     /* 1.0 * vector đơn vị hướng x + 2.0 * vector đơn vị hướng y */
18:     v = vAdd( vMul( 1.0, gXUnitVector ), vMul( 2.0, gYUnitVector ) );
19:     printVector2d( v );          /* Hiển thị nội dung vector 2 chiều v */
20:     printf( "norm is %g\n", norm( v ) ); /* Hiển thị độ lớn của vector v */
21:
22:     return 0;
23: }				
			

Ở dòng 5, để sử dụng hàm printf() của module nhập xuất chuẩn, ta đọc tệp tiêu đề stdio.h của module nhập xuất chuẩn. Ở dòng 6, ta đọc tệp tiêu đề tự tạo vector2d.h. Hàm printVector2d() ở dòng 8~10 hiển thị nội dung của vector 2 chiều kiểu vector2d được truyền vào tham số. Để hiển thị vector (giá trị của thành viên double x và y), ta sử dụng hàm printf().

Thực ra, hàm printf() có dạng như sau:

 
printf( Chuỗi_ký_tự, Tham_số_thứ_2, Tham_số_thứ_3, ... )

Như đã nói ở trước, hàm printf() hiển thị chuỗi ký tự ở tham số thứ 1, nhưng nếu ta đặt vào đó các chỉ định chuyển đổi bắt đầu bằng %, thì chúng sẽ được thay thế bằng nội dung giá trị của tham số thứ 2 trở đi tương ứng với chỉ định chuyển đổi đó rồi hiển thị ra. Ví dụ, khi hiển thị giá trị kiểu int dưới dạng thập phân, ta dùng chỉ định chuyển đổi %d, còn để hiển thị giá trị kiểu double, ta dùng chỉ định chuyển đổi %g. Vì vậy, khi biến kiểu int i chứa giá trị 10, biến kiểu double d chứa giá trị 1.23, nếu ta gọi hàm printf() như sau:

 
printf( "i is %d, d is %g\n", i, d );

thì kết quả hiển thị sẽ là:

i is 10, d is 1.23

Trong chuỗi ký tự tham số thứ 1 của hàm printf(), ta có thể viết bao nhiêu chỉ định chuyển đổi cũng được. Và ta phải truyền các tham số thực từ tham số thứ 2 trở đi sao cho tương ứng với các chỉ định chuyển đổi xuất hiện trong chuỗi ký tự tham số thứ 1. Trong ví dụ trên, các chỉ định chuyển đổi xuất hiện trong tham số thứ 1 theo thứ tự %d, %g, nên tham số thực thứ 2 ta truyền biến kiểu int, tham số thứ 3 ta truyền biến kiểu double. Các hàm thông thường có số lượng tham số được quy định nghiêm ngặt, nhưng hàm printf() thì có thể truyền bao nhiêu tham số cũng được.

Trong ngôn ngữ C cũng cung cấp phương pháp để tạo ra các hàm có thể truyền số lượng tham số tùy ý như vậy, nhưng ở đây tôi xin phép lược bỏ chi tiết. Ngoài ra, hàm printf() còn có nhiều chức năng hiển thị dữ liệu phong phú khác ngoài những gì đã giới thiệu ở đây, nhưng tôi cũng xin phép bỏ qua chi tiết.

Quay lại, dòng 13~23 là hàm main(). Đầu tiên, ở dòng 15, biến v kiểu vector2d được khai báo kèm khởi tạo. Ở dòng 18, ta gán vào biến v một vector là tổng hợp của vector theo hướng trục $x$:

1.0 × vector đơn vị hướng x gXUnitVector

và vector theo hướng trục $y$:

2.0 × vector đơn vị hướng y gYUnitVector

 

Hãy chú ý rằng ta đang sử dụng các hằng số toàn cục gXUnitVector, gYUnitVector, và các hàm toàn cục vMul(), vAdd() đã được định nghĩa trong module vector 2 chiều. Tiếp theo dòng 19 hiển thị nội dung của vector v, và dòng 20 hiển thị độ lớn của v. Dòng 20 cũng sử dụng hàm toàn cục norm() của module vector 2 chiều.

Biên dịch và chạy chương trình (Program Compilation and Execution / プログラムのコンパイルと実行)

Tôi xin giới thiệu quy trình tạo chương trình có thể thực thi từ chương trình nguồn usevector2d.c trong List 0-4, lấy ví dụ với trình biên dịch tên là gcc thường dùng trên UNIX. Ở đây, dấu nhắc lệnh của UNIX shell là %. Đầu tiên, hãy biên dịch vector2d.c của List 0-3 để tạo module vector 2 chiều (đặt tên là vector2d.o). Để làm việc đó, ta gõ:

% gcc -c vector2d.c -o vector2d.o

-c là chỉ định chỉ thực hiện biên dịch (nếu không có chỉ định này, gcc sẽ cố gắng tự động thực hiện cả liên kết). -o là chỉ định tên tệp được xuất ra, ở đây ta chỉ định tên tệp xuất ra là vector2d.o. Tiếp theo, biên dịch usevector2d.c của List 0-4 để tạo usevector2d.o. Tương tự như trước, ta gõ:

% gcc -c usevector2d.c -o usevector2d.o

Vậy là các tệp module vector2d.ousevector2d.o đã được tạo ra. Nếu ta liên kết (link) 2 module này cùng với module nhập xuất chuẩn và module toán học trong thư viện chuẩn, ta có thể tạo ra chương trình thực thi được. Để làm việc đó, ta làm như sau:

% gcc vector2d.o usevector2d.o -lm -o usevector2d

-lm xuất hiện ở đây là chỉ định: hãy liên kết module toán học (math module). Khi thực hiện lệnh trên, gcc sẽ gọi Bộ liên kết (Linker / リンカ) để liên kết vector2d.o, usevector2d.o, module toán học, và module nhập xuất chuẩn, tạo ra chương trình thực thi được usevector2d. Ở đây, ta không chỉ định liên kết module nhập xuất chuẩn, nhưng trong gcc, phần lớn Thư viện chuẩn (Standard Library / 標準ライブラリ) của ngôn ngữ C được thiết lập để tự động liên kết. Tuy nhiên, module toán học có kích thước lớn, nếu tự động liên kết sẽ khiến kích thước chương trình trở nên quá lớn, nên nó không được tự động liên kết mà được thiết lập để không liên kết trừ khi ta chỉ định rõ ràng. Để chạy chương trình usevector2d thực tế đã tạo, ta gõ: 

% ./usevector2d

Ngoài ra, đến đây tôi đã chia việc tạo chương trình thành 3 giai đoạn để dễ hiểu luồng từ tạo module đến liên kết. Tuy nhiên, nếu sử dụng khéo léo gcc, ta có thể tạo chương trình một mạch từ nhiều tệp nguồn mà không cần tạo các tệp module trung gian. Để làm việc đó, ta làm như sau:

% gcc vector2d.c usevector2d.c -lm -o usevector2d

Tổng kết (Summary / まとめ)

Chúng ta đã chạy lướt qua phần ôn tập ngôn ngữ C, bạn thấy thế nào? Nói một cách ngắn gọn, “Việc xây dựng chương trình bằng ngôn ngữ C là định nghĩa các kiểu để biểu diễn thông tin cần xử lý, định nghĩa các hàm xử lý các thông tin đó, tập hợp các chức năng liên quan thành module, và cấu thành một chuỗi các lời gọi hàm bắt đầu từ hàm main()“. Sau khi hiểu rõ những đặc trưng như vậy của ngôn ngữ C, nếu bạn làm rõ được đâu là điểm giống và đâu là điểm khác với C++, tôi nghĩ rằng nó chắc chắn sẽ có ích cho việc lập trình C++.

Leave a Comment

Chia sẻ:

0.3. Hệ thống/ Bố cục trong ngôn ngữ C

Or copy link

CONTENTS