Hai câu hỏi về mọi biến
Mỗi biến trong C có hai thuộc tính tách biệt, và nhầm lẫn chúng là nguồn gốc của phần lớn những điều bất ngờ trên trang này:
- Phạm vi (scope): tên có thể được dùng ở đâu. Câu hỏi của thời điểm biên dịch.
- Vòng đời (lifetime): vùng lưu trữ tồn tại bao lâu. Câu hỏi của thời điểm chạy.
Chúng thường đi cùng nhau (một biến cục bộ thông thường được nhìn thấy trong khối của nó và cũng sống đúng chừng đó), nhưng static tách chúng ra, nên đây là từ khóa đáng hiểu kỹ ở trang này.
Phạm vi khối
Một biến khai báo bên trong cặp ngoặc nhọn được nhìn thấy từ chỗ khai báo đến dấu ngoặc đóng, và không ở đâu khác.
Khối bên trong nhìn thấy outer, vì nó nằm lồng bên trong. Khối bên ngoài không nhìn thấy inner. Điều này áp dụng cho mọi cặp ngoặc nhọn: thân hàm, thân if, thân vòng lặp, hay một khối trơn như ví dụ trên.
Vòng lặp làm điều này cụ thể hơn. Một biến đếm khai báo trong phần đầu for thuộc về vòng lặp:
for (int i = 0; i < 5; i++) {
/* i lives here */
}
/* i does not exist here */
Đó chính là điều bạn muốn: hai vòng lặp trong cùng một hàm có thể mỗi cái dùng i mà không ảnh hưởng nhau, và biến đếm không thể bị đọc nhầm sau vòng lặp. Nếu bạn cần giá trị đó về sau, chẳng hạn chỉ số nơi việc tìm kiếm dừng lại, hãy khai báo nó trước vòng lặp.
Tham số hàm có phạm vi là thân hàm, nên chúng hoạt động như các biến cục bộ khai báo ở đầu hàm.
C99 cho phép khai báo ở bất cứ đâu trong một khối, không chỉ ở đầu. Hãy khai báo biến ở chỗ bạn cần nó lần đầu; một biến có phạm vi ngắn là một biến có ít cách để sai hơn.
Vòng đời: lưu trữ tự động
Một biến cục bộ thông thường có kiểu lưu trữ tự động (automatic): nó ra đời khi luồng điều khiển đi vào khối của nó và bị hủy khi luồng điều khiển rời khỏi khối. Bộ nhớ của nó nằm trên stack.
Đoạn này in ra 1 ba lần. Mỗi lời gọi nhận một count hoàn toàn mới, khởi tạo bằng 0, và bị bỏ đi khi trả về. Đây cũng là lý do mỗi frame của một hàm đệ quy có bản sao riêng của mọi biến cục bộ.
Hai hệ quả cần nói rõ. Một biến cục bộ chưa khởi tạo chứa giá trị rác, không phải số 0, mà là bất kỳ byte nào đang nằm trên stack. Và trả về con trỏ tới một biến cục bộ là undefined behavior, vì vùng lưu trữ biến mất ngay khi hàm trả về:
/* BUG: buf does not exist after the return */
char *broken(void) {
char buf[64] = "hello";
return buf;
}
Biến cục bộ static: trí nhớ giữa các lần gọi
Thêm static vào một biến cục bộ, vòng đời của nó trở thành toàn bộ chương trình, trong khi phạm vi vẫn y nguyên.
Giờ thì bộ đếm in ra 1, 2, 3. Phần = 0 chạy một lần, trước khi main bắt đầu, không phải ở mỗi lời gọi.
Ba quy tắc cho biến cục bộ static:
- Giá trị khởi tạo phải là một biểu thức hằng, vì nó được áp dụng khi chương trình bắt đầu, không phải lúc chạy.
- Không có giá trị khởi tạo, một biến
staticđược khởi tạo bằng 0 (khác với biến cục bộ tự động, vốn chứa rác). - Biến vẫn riêng tư trong hàm. Không đoạn code nào khác có thể gọi tên nó.
Điểm cuối cùng chính là sức hút: một hàm có thể ghi nhớ điều gì đó giữa các lần gọi mà không phải lộ ra một biến toàn cục ai cũng sửa được. Hãy dùng nó cho bộ đếm số lần gọi, bảng tra cứu được dựng ở lần dùng đầu tiên, và cờ khởi tạo một lần.
Cái giá phải trả là hàm không còn là một ánh xạ thuần túy từ đầu vào sang đầu ra: cùng đối số có thể cho ra kết quả khác nhau, nên khó kiểm thử hơn và không an toàn khi gọi từ nhiều luồng cùng lúc nếu không có cơ chế bảo vệ. Hãy dùng biến cục bộ static có chủ đích, đừng dùng chỉ vì tiện.
Phạm vi tệp: biến toàn cục
Một biến khai báo bên ngoài mọi hàm có phạm vi tệp. Nó được nhìn thấy từ chỗ khai báo đến cuối tệp, trong mọi hàm bên dưới, và vòng đời của nó là toàn bộ chương trình.
Biến toàn cục mặc định được khởi tạo bằng 0, nên totalOperations bắt đầu từ 0 ngay cả khi không có = 0.
Mặc định, một biến toàn cục còn có liên kết ngoài (external linkage): các tệp .c khác trong cùng chương trình có thể truy cập nó bằng cách khai báo extern:
/* in stats.c */
int totalOperations = 0; /* the definition - exactly one in the program */
/* in main.c */
extern int totalOperations; /* a declaration: "it exists somewhere" */
Hãy đặt khai báo extern trong một tệp header để mọi nơi dùng đều thấy cùng một khai báo. Lưu ý sự bất đối xứng: khai báo extern nói rằng biến tồn tại, và đúng một tệp .c phải thật sự định nghĩa nó.
Vì sao biến toàn cục gây hại
Biến toàn cục là cách dễ nhất để chia sẻ dữ liệu và cũng là cách dễ nhất để tạo ra những bug bạn không khoanh vùng được. Bốn vấn đề cụ thể:
Bất cứ thứ gì cũng có thể thay đổi chúng. Khi totalOperations giữ một giá trị sai, thủ phạm có thể là bất kỳ dòng nào trong bất kỳ tệp nào. Với một tham số, nghi phạm chỉ là những chỗ gọi hàm bạn nhìn thấy được.
Các hàm trở nên không kiểm thử được. Một hàm đọc biến toàn cục không thể được gọi riêng lẻ; bạn phải dựng trạng thái toàn cục trước, và nhớ đặt lại nó sau đó.
Tên có hiệu lực trong toàn chương trình. Một biến toàn cục tên count hay buffer sớm muộn cũng sẽ trùng với biến của người khác.
Giá trị khởi tạo của chúng phải là hằng lúc biên dịch. Trong C, biến toàn cục chỉ có thể khởi tạo bằng một biểu thức hằng: int limit = readConfig(); sẽ không biên dịch được, và int b = a * 2; ở phạm vi tệp cũng vậy. Mọi giá trị cần tính toán đều phải được gán lúc chạy bởi một hàm thiết lập nào đó, và mọi tệp động vào biến toàn cục trước khi lời gọi đó chạy sẽ âm thầm thấy giá trị 0.
Gần như luôn có cách thay thế: truyền giá trị qua tham số, trả về kết quả, và gom các trạng thái liên quan vào một struct được truyền đi một cách tường minh. Vẫn có những ngoại lệ chính đáng, như một đối tượng cấu hình dùng cho toàn chương trình hay một handle ghi log, và ngay cả những trường hợp đó cũng nên là static ở phạm vi tệp kèm các hàm truy cập, đó là nội dung của phần tiếp theo.
static ở phạm vi tệp: tính riêng tư
Trên một biến toàn cục hoặc một hàm, static mang nghĩa hoàn toàn khác so với trên biến cục bộ: liên kết trong (internal linkage). Tên đó trở thành riêng tư trong tệp .c của nó và không thể được truy cập từ tệp nào khác.
/* counter.c */
#include "counter.h"
static int count = 0; /* private to this file - no other file can touch it */
static void validate(void) { /* a private helper, not part of the interface */
if (count < 0) count = 0;
}
void increment(void) { /* public: declared in counter.h */
count++;
validate();
}
int getCount(void) { /* public */
return count;
}
Một tệp khác có thể gọi increment và getCount, nhưng không nhìn thấy count và không gọi được validate: trình liên kết sẽ không phân giải những tên đó. Đó chính là hệ thống module của C, dù đơn sơ: một tệp header khai báo các hàm công khai, và static cho mọi thứ còn lại.
Hai lợi ích thực tế ngoài sự gọn gàng. Hai tệp có thể mỗi tệp có một static void validate(void) mà không trùng nhau, điều không thể nếu các hàm đó là công khai. Và trình biên dịch biết một hàm static không có nơi gọi nào bên ngoài tệp, nên nó có thể inline mạnh tay hơn hoặc cảnh báo khi hàm không được dùng.
Vậy là từ khóa này có hai nghĩa riêng biệt, tùy vào việc nó xuất hiện ở đâu:
static on a LOCAL variable -> lifetime becomes permanent (scope unchanged)
static at FILE scope -> linkage becomes internal (lifetime unchanged)
Che khuất tên
Khai báo trong phạm vi bên trong một tên đã có ở phạm vi bên ngoài sẽ che khuất (shadow) tên bên ngoài: trong phạm vi bên trong, tên đó chỉ tới biến mới và biến bên ngoài không còn truy cập được.
Điều này hợp lệ, và đôi khi là có chủ ý. Nhưng thường thì đó là tai nạn, và là tai nạn đắt giá: một hàm định cập nhật biến toàn cục lại cập nhật một biến cục bộ cùng tên, và biến toàn cục không bao giờ thay đổi. Điều tương tự xảy ra khi một biến cục bộ che khuất một tham số, khiến phép gán bạn định dành cho đầu vào chẳng đi đến đâu.
gcc -Wshadow báo mọi trường hợp. Nó không nằm trong -Wall, nên hãy bật nó một cách tường minh:
gcc -Wall -Wextra -Wshadow program.c -o program
Thói quen giúp tránh hẳn vấn đề này là đặt tên theo vai trò thay vì theo kiểu: totalScore và itemScore không thể che khuất nhau, còn hai biến cùng tên n thì sớm muộn cũng sẽ như vậy.
Tra cứu nhanh
declared inside a block block scope, automatic lifetime, garbage if uninitialised
function parameter block scope of the function body, a copy of the argument
static inside a function block scope, PROGRAM lifetime, zero-initialised, kept between calls
declared outside all functions file scope, program lifetime, zero-initialised, visible to other files
static outside all functions file scope, program lifetime, PRIVATE to this .c file
extern declaration names a variable defined in another file
Câu hỏi thường gặp
Phạm vi (scope) trong C là gì?
Là vùng code mà một tên được nhìn thấy. Một biến khai báo bên trong một khối (một cặp ngoặc nhọn) chỉ được nhìn thấy trong khối đó: đó là phạm vi khối. Một biến khai báo bên ngoài mọi hàm có phạm vi tệp và được nhìn thấy từ chỗ khai báo đến cuối tệp.
static làm gì với một biến cục bộ trong C?
Nó thay đổi vòng đời của biến mà không thay đổi phạm vi. Một biến cục bộ static được tạo một lần, khởi tạo một lần và giữ giá trị giữa các lần gọi, nhưng vẫn chỉ được nhìn thấy bên trong hàm của nó. Đó là cách cho một hàm có trí nhớ mà không cần dùng biến toàn cục.
static và biến toàn cục khác nhau thế nào trong C?
Một biến toàn cục thông thường được mọi tệp trong chương trình nhìn thấy, và các tệp khác có thể truy cập nó bằng extern. Một biến hoặc hàm static ở phạm vi tệp là riêng tư với đúng một tệp .c đó: các tệp khác không thể liên kết tới nó. static ở phạm vi tệp là để che giấu; static trên biến cục bộ là về vòng đời.
Vì sao biến toàn cục không tốt trong C?
Bất kỳ hàm nào cũng có thể thay đổi chúng, nên bug có thể bắt nguồn từ bất cứ đâu trong chương trình; các hàm đọc chúng không thể kiểm thử riêng lẻ; và tên biến chiếm chỗ trong toàn bộ chương trình, dễ gây trùng tên. Hãy truyền giá trị qua tham số và trả về kết quả, còn khi thật sự cần trạng thái dùng chung, hãy khai báo nó static ở phạm vi tệp để chỉ một tệp có thể động vào.