Kiểu dữ liệu, biến và hằng
Mỗi mẩu dữ liệu trong C đều có một kiểu. Kiểu quyết định dữ liệu chiếm bao nhiêu byte trong bộ nhớ và lưu được những giá trị nào. Trong bài này ta học bốn kiểu cơ bản, cách khai báo và khởi tạo biến, cùng cách dùng hằng số để mã rõ ràng hơn.
Vì sao C lại bắt ta khai báo kiểu, trong khi nhiều ngôn ngữ ngày nay không bắt? Câu trả lời nằm ở tầng thấp nhất của máy tính. Bộ nhớ chỉ là một dãy byte, và một byte tự nó không mang ý nghĩa gì cả. Cùng một chuỗi bốn byte, nếu ta bảo máy hiểu là số nguyên thì nó cho ra một con số; nếu bảo hiểu là số thực thì cho ra một con số khác hẳn; nếu bảo hiểu là bốn ký tự thì lại ra một chuỗi chữ. Byte không tự nói nó là gì, chính kiểu mới nói. Khi bạn viết int tuoi;, bạn đang giao cho trình biên dịch ba mệnh lệnh cùng lúc: dành đúng bốn byte, hiểu bốn byte đó theo quy tắc số nguyên có dấu, và chỉ cho phép các phép toán hợp lệ với số nguyên.
Đổi lại sự phiền phức khi khai báo, ta được ba thứ rất đáng giá. Trình biên dịch bắt được nhiều lỗi ngay trước khi chương trình chạy, chẳng hạn khi ta lỡ cộng một số vào một ký tự sai cách. Máy sinh ra mã chạy nhanh vì nó biết chính xác kích thước mọi thứ ngay từ lúc biên dịch, không phải tra cứu lúc chạy. Và bộ nhớ được dùng tiết kiệm, không có phần mô tả kiểu đi kèm từng giá trị. Đó là lý do C vẫn là ngôn ngữ của hệ điều hành và thiết bị nhúng sau nửa thế kỷ.
Thử ngay: xem biến và hằng chạy từng bước
Đoạn mã sau khai báo bốn biến với bốn kiểu khác nhau, gán giá trị cho chúng, rồi thay đổi một biến. Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi giá trị từng biến xuất hiện và biến đổi trên ngăn xếp. Chưa cần hiểu từng dòng: bốn kiểu ấy chứa được những gì, khai báo và khởi tạo ra sao, các mục bên dưới sẽ nói rõ.
1#include <stdio.h>23int main(void) {4 int tuoi = 20;5 float chieu_cao = 1.70;6 char hang = 'A';7 const float PI = 3.14;8 tuoi = 21;9 printf("%d %c\n", tuoi, hang);10 return 0;11}
Quan sát kỹ bước gán lại tuoi = 21: ô nhớ không di chuyển, chỉ giá trị bên trong thay đổi. Đó chính là ý nghĩa của chữ biến: một ô nhớ có tên mà nội dung có thể biến đổi.
Đoạn mã hoàn chỉnh có thêm các lệnh in cho từng biến như sau.
#include <stdio.h>
int main(void) {
int tuoi = 20; // declare and initialize at once
float chieu_cao = 1.70; // height in meters
char hang = 'A'; // a single character
const float PI = 3.14; // a named constant, cannot change later
printf("Tuoi: %d\n", tuoi);
printf("Chieu cao: %.2f\n", chieu_cao);
printf("Hang: %c\n", hang);
printf("PI: %.2f\n", PI);
return 0;
}
Các định dạng %d, %f, %c trong printf tương ứng với việc in số nguyên, số thực và ký tự; ta sẽ học chi tiết ở bài sau.
Kiểu dữ liệu cơ bản
Bốn kiểu cơ bản người mới cần nắm là int, float, double và char.
intlưu số nguyên, ví dụ0,-5,1000. Đây là kiểu dùng nhiều nhất để đếm, đánh chỉ số.floatlưu số thực dấu phẩy động, ví dụ3.14, độ chính xác vừa phải.doublecũng lưu số thực nhưng chính xác hơn và chứa được số lớn hơnfloat. Khi tính toán số thực ta thường ưu tiêndouble.charlưu một ký tự, ví dụ'A','?'. Ký tự được đặt trong dấu nháy đơn. Thực chấtcharlưu mã số của ký tự theo bảng mã.
Kích thước thực tế của mỗi kiểu có thể khác nhau đôi chút tùy máy và trình biên dịch, nhưng trên các hệ thống phổ biến hiện nay, các giá trị sau là điển hình.
| Kiểu | Kích thước | Phạm vi điển hình | Dùng cho |
|---|---|---|---|
char | 1 byte | từ -128 đến 127 | một ký tự hoặc số nguyên rất nhỏ |
int | 4 byte | khoảng -2,1 tỷ đến 2,1 tỷ | số nguyên thông dụng, biến đếm |
float | 4 byte | khoảng cộng trừ 3,4 nhân 10 mũ 38, chừng 7 chữ số nghĩa | số thực, độ chính xác vừa |
double | 8 byte | khoảng cộng trừ 1,7 nhân 10 mũ 308, chừng 15 chữ số nghĩa | số thực, độ chính xác cao |
Kiểu số nguyên không lưu được số vô hạn lớn. Một biến int chỉ chứa được tới khoảng hai tỷ; vượt quá là tràn số và kết quả không còn tin được. Khi cần số rất lớn, ta dùng kiểu rộng hơn như long long. Hiện tượng tràn số bàn kỹ ở mục Tràn số bên dưới.
Toán tử sizeof cho biết một kiểu chiếm bao nhiêu byte trên máy đang dùng. Chương trình sau in kích thước của bốn kiểu cơ bản.
#include <stdio.h>
int main(void) {
printf("char : %zu byte\n", sizeof(char)); // usually 1
printf("int : %zu byte\n", sizeof(int)); // usually 4
printf("float : %zu byte\n", sizeof(float)); // usually 4
printf("double : %zu byte\n", sizeof(double)); // usually 8
return 0;
}
Định dạng %zu dùng để in một kích thước; ta sẽ học kỹ các định dạng in ở bài sau.
Kích thước quyết định phạm vi
Con số trong cột phạm vi ở bảng trên không phải quy ước tuỳ tiện, nó suy ra trực tiếp từ số byte. Một char có 1 byte tức 8 bit, mà 8 bit tạo ra được 256 tổ hợp khác nhau. C dùng một bit để ghi dấu, phần còn lại chia đôi cho vùng âm và vùng dương, thành ra phạm vi từ -128 tới 127, đúng 256 giá trị. Tương tự, int có 4 byte tức 32 bit, cho khoảng bốn tỷ tổ hợp, chia đôi thành khoảng âm hai tỷ tới dương hai tỷ. Nhớ mối liên hệ này thì không cần học thuộc bảng: cứ biết số byte là suy ra được phạm vi.
Kiểu thực thì tính khác. float cũng chiếm 4 byte như int nhưng chứa được số tới mười mũ ba tám. Không có phép màu nào ở đây, chỉ có một sự đánh đổi: số thực không lưu giá trị chính xác mà lưu theo dạng dấu phẩy động, gồm một phần định trị và một phần số mũ, giống cách ta viết ba phẩy mười bốn nhân mười mũ tám trong vật lý. Số mũ cho phép với tới những giá trị rất lớn hoặc rất nhỏ, nhưng phần định trị chỉ có hữu hạn bit nên chỉ giữ được chừng bảy chữ số nghĩa với float và chừng mười lăm với double. Nói gọn: int đổi phạm vi lấy độ chính xác tuyệt đối, float đổi độ chính xác lấy phạm vi.
Hệ quả thực tế: số thực trong máy thường là xấp xỉ. Cộng 0.1 với 0.2 cho ra một số cực gần 0.3 nhưng không bằng đúng 0.3, vì 0.1 trong hệ nhị phân là số vô hạn tuần hoàn và máy buộc phải cắt bớt. Vì thế ta không bao giờ so sánh bằng tuyệt đối giữa hai số thực, mà so xem chúng có đủ gần nhau hay không.
Quy tắc chọn kiểu thì thực dụng. Dùng int cho mọi thứ đếm được. Dùng double khi cần số thực, đừng dùng float trừ khi có lý do rõ ràng về bộ nhớ, vì double chính xác gấp đôi mà máy hiện nay tính nó không hề chậm hơn. Dùng char cho ký tự, đừng lạm dụng nó để lưu số nhỏ vì phạm vi tới 127 rất dễ tràn.
Tràn số
Điều gì xảy ra khi ta cố nhét vào một biến giá trị vượt quá phạm vi của nó? Câu trả lời khác nhau tuỳ kiểu, và đây là chỗ rất nhiều tài liệu nói sai.
Với số nguyên có dấu như int, vượt quá phạm vi là hành vi không xác định (undefined behavior). Chuẩn C không hứa hẹn gì cả: máy có thể cho ra một con số quay vòng, có thể cho ra một con số khác, và trình biên dịch còn được phép tối ưu hoá dựa trên giả định rằng tràn không bao giờ xảy ra, khiến cả đoạn mã quanh đó biến dạng theo cách bạn không ngờ. Vì vậy câu "INT_MAX + 1 bằng INT_MIN" là sai: quay vòng chỉ là thứ ta hay quan sát thấy trên một số máy, không phải thứ ngôn ngữ bảo đảm. Cách nói đúng là INT_MAX + 1 đơn giản không được phép viết.
Với số nguyên không dấu như unsigned int thì ngược lại: chuẩn C quy định rõ phép tính được lấy dư, nên quay vòng ở đây là hành vi hợp lệ và đoán trước được. Đó là chỗ hình ảnh đồng hồ đo quãng đường xe máy dùng được: đồng hồ có sáu chữ số, chạy tới 999999 rồi cộng thêm một cây số thì quay về 000000 vì không còn chỗ ghi chữ số thứ bảy.
Vì tràn có dấu không được phép xảy ra, cách làm đúng là kiểm tra trước khi cộng chứ không phải cộng rồi xem kết quả.
#include <stdio.h>
#include <limits.h>
int main(void) {
printf("INT_MAX = %d\n", INT_MAX);
// printf("%d\n", INT_MAX + 1); // undefined behavior: never write this
int a = INT_MAX, b = 1;
if (a > INT_MAX - b) { // check BEFORE adding, not after
printf("Phep cong se tran, khong thuc hien.\n");
} else {
printf("a + b = %d\n", a + b);
}
unsigned int u = UINT_MAX; // unsigned: wrap-around IS defined
printf("UINT_MAX = %u\n", u);
printf("UINT_MAX + 1 = %u\n", u + 1); // defined by the standard: 0
return 0;
}
Chương trình in ra INT_MAX, rồi dòng "Phep cong se tran, khong thuc hien.", rồi UINT_MAX và 0. Điều kiện a > INT_MAX - b là khuôn mẫu chuẩn: nó chỉ dùng các phép tính không bao giờ vượt phạm vi, nên phát hiện được tràn mà không hề gây tràn.
Điều nguy hiểm nhất của tràn số là nó im lặng: không lỗi biên dịch, không cảnh báo, thường cũng không sập chương trình, chỉ lặng lẽ cho ra một con số sai rồi mọi tính toán phía sau sai theo. Bật cờ -fsanitize=undefined khi biên dịch với gcc sẽ báo đúng dòng gây tràn có dấu, rất đáng dùng khi học.
Tràn số hay xảy ra nhất trong các phép trung gian chứ không phải ở kết quả cuối. Ví dụ tính trung bình của hai số nguyên lớn bằng (a + b) / 2: kết quả cuối cùng chắc chắn nằm trong phạm vi int, nhưng phép cộng a + b ở giữa có thể đã tràn rồi, và tràn xong thì chia cho hai cũng vô ích. Cách viết an toàn là a + (b - a) / 2, không có bước trung gian nào vượt phạm vi. Cùng cái bẫy này xuất hiện khi tính giai thừa hay luỹ thừa: chỉ vài bước là vượt hai tỷ. Nguyên nhân gốc luôn giống nhau, đó là ta chỉ nghĩ tới phạm vi của kết quả mà quên phạm vi của các giá trị trung gian.
Ép kiểu ngầm và ép kiểu tường minh
Khi một biểu thức trộn nhiều kiểu, C không từ chối mà tự động đổi kiểu để tính cho được, gọi là ép kiểu ngầm (implicit conversion). Quy tắc chung là kiểu hẹp bị kéo lên thành kiểu rộng: int gặp double thì int được đổi thành double rồi mới tính. Hướng này an toàn vì không mất thông tin. Rắc rối nằm ở chiều ngược lại: gán một double vào một int thì phần thập phân bị cắt bỏ chứ không làm tròn, nên 3.9 thành 3 chứ không thành 4.
#include <stdio.h>
int main(void) {
int a = 7, b = 2;
printf("%d\n", a / b); // 3, integer division truncates
printf("%f\n", a / (double) b); // 3.500000, explicit cast to double
printf("%f\n", (double) (a / b)); // 3.000000, too late: damage already done
double x = 3.9;
int y = (int) x; // explicit cast, cuts the fraction
printf("%d\n", y); // 3, not 4
return 0;
}
Ba dòng printf đầu tiên dạy trọn bài học. Biểu thức a / b có cả hai vế là int nên C dùng phép chia nguyên, kết quả là 3 và phần dư bị vứt ngay tại chỗ. Ở dòng thứ hai, ta viết (double) b để nói với trình biên dịch rằng hãy coi b là số thực; a được kéo lên double theo, phép chia trở thành phép chia thực và cho 3.5. Ở dòng thứ ba, ta ép kiểu sau khi đã chia, mà lúc đó phần dư đã mất rồi nên có ép cũng chỉ được 3.0. Thời điểm ép kiểu quan trọng ngang với việc có ép hay không.
Cú pháp ép kiểu tường minh là đặt tên kiểu trong ngoặc đơn ngay trước giá trị. Hãy coi nó như một lời tuyên bố có chủ đích, rằng tôi biết mình đang mất phần thập phân và tôi cố ý làm vậy. Đừng rắc ép kiểu khắp nơi để dập tắt cảnh báo của trình biên dịch, vì cảnh báo thường đang chỉ đúng vào một lỗi thật.
Hằng số
Hằng là một giá trị cố định, không thay đổi trong khi chương trình chạy. Có vài dạng hằng quen thuộc: hằng số nguyên như 0, 42, -17; hằng số thực như 3.14, 0.5, 2.0; hằng ký tự như 'A', 'z', '5' đặt trong nháy đơn; hằng chuỗi như "Hello" đặt trong nháy kép, là dãy nhiều ký tự.
Ngoài ra, khi muốn đặt tên cho một hằng để mã dễ đọc, ta dùng từ khóa const. Ví dụ const float PI = 3.14; khai báo một hằng tên PI có giá trị cố định. Sau đó nếu ta lỡ tay viết lệnh gán lại cho PI, trình biên dịch sẽ báo lỗi, giúp tránh sửa nhầm.
Phân biệt nháy đơn và nháy kép: 'A' là một ký tự đơn lẻ kiểu char, còn "A" là một chuỗi chứa ký tự A kèm dấu kết thúc. Hai thứ này khác nhau về bản chất, sẽ rõ hơn khi học chuỗi ở bài về mảng.
Khai báo và khởi tạo biến
Biến là một ô nhớ có tên, dùng để chứa dữ liệu mà chương trình có thể đọc và thay đổi. Trước khi dùng một biến trong C, ta bắt buộc phải khai báo nó, tức là báo cho trình biên dịch biết tên biến và kiểu của nó. Cú pháp khai báo gồm tên kiểu rồi tới tên biến, ví dụ int tuoi; khai báo một biến tên tuoi kiểu số nguyên. Lúc này ô nhớ đã được dành riêng nhưng chưa có giá trị xác định.
Việc đặt giá trị ban đầu cho biến gọi là khởi tạo. Ta có thể khai báo và khởi tạo trong cùng một dòng, ví dụ int tuoi = 20;. Cũng có thể khai báo trước rồi gán giá trị sau bằng dấu bằng, ví dụ về sau viết tuoi = 21; để thay giá trị mới.
Nếu dùng một biến chưa được khởi tạo, biến đó chứa giá trị rác bất kỳ còn sót trong ô nhớ, dẫn tới kết quả sai và khó đoán. Hãy tập thói quen khởi tạo biến ngay khi khai báo.
Phạm vi của biến
Khai báo một biến chưa đủ để trả lời câu hỏi biến này dùng được ở đâu và sống được bao lâu. Hai câu hỏi ấy thuộc về phạm vi (scope) và thời gian sống (lifetime), và trong C chúng gắn chặt với cặp ngoặc nhọn.
Biến khai báo bên trong một khối ngoặc nhọn là biến cục bộ: nó chỉ nhìn thấy được từ chỗ khai báo tới ngoặc nhọn đóng của khối, và chỉ sống trong khoảng thời gian khối ấy đang chạy. Khi khối kết thúc, ô nhớ của biến được thu hồi. Đây là lý do hai hàm khác nhau đặt tên biến trùng nhau vẫn không xung đột: mỗi hàm có một khung riêng trên ngăn xếp, hai biến cùng tên nằm ở hai ô nhớ hoàn toàn khác nhau.
Biến khai báo bên ngoài mọi hàm là biến toàn cục: nhìn thấy được từ mọi hàm và sống suốt thời gian chương trình chạy. Nghe tiện, nhưng chính sự tiện lợi đó là cái bẫy. Khi một biến toàn cục mang giá trị sai, thủ phạm có thể là bất kỳ hàm nào, nên không có cách nào thu hẹp phạm vi điều tra. Với biến cục bộ thì chỉ mã trong đúng một khối chạm được vào nó, nghi phạm luôn hữu hạn. Quy tắc thực hành: khai báo biến ở phạm vi hẹp nhất còn đủ dùng.
#include <stdio.h>
int dem_toan_cuc = 0; // global: visible everywhere, lives forever
void tang(void) {
int tam = 1; // local to tang(), destroyed when tang() returns
dem_toan_cuc = dem_toan_cuc + tam;
}
int main(void) {
int tam = 100; // a different variable, also named tam
tang();
tang();
printf("toan cuc = %d, tam trong main = %d\n", dem_toan_cuc, tam);
return 0;
}
Chương trình in ra toan cuc = 2, tam trong main = 100. Hai biến tên tam không hề liên quan tới nhau: một cái nằm trên khung ngăn xếp của tang, sinh ra rồi mất đi hai lần, cái kia nằm trên khung của main và không bị hàm tang chạm tới. Còn dem_toan_cuc thì nằm ở một vùng nhớ khác hẳn, tồn tại độc lập với mọi lời gọi hàm nên nó tích luỹ được giá trị qua các lần gọi.
Biến static
Từ đoạn mã trên nảy ra một câu hỏi tự nhiên: có cách nào để một biến vừa riêng tư như biến cục bộ, vừa nhớ được giá trị qua các lần gọi hàm như biến toàn cục hay không? Có, đó chính là từ khoá static.
#include <stdio.h>
void dem_luot_goi(void) {
static int lan = 0; // initialized once, keeps its value between calls
int thuong = 0; // reset to 0 on every call
lan = lan + 1;
thuong = thuong + 1;
printf("static = %d, thuong = %d\n", lan, thuong);
}
int main(void) {
dem_luot_goi(); // static = 1, thuong = 1
dem_luot_goi(); // static = 2, thuong = 1
dem_luot_goi(); // static = 3, thuong = 1
return 0;
}
Kết quả cho thấy rõ sự khác biệt: biến static đếm lên 1, 2, 3 còn biến thường luôn là 1. Cơ chế bên dưới như sau. Biến static không nằm trên ngăn xếp mà nằm ở vùng nhớ tĩnh, cùng khu với biến toàn cục, nên nó không bị thu hồi khi hàm kết thúc. Dòng khởi tạo = 0 chỉ chạy đúng một lần trước khi chương trình bắt đầu, chứ không chạy lại mỗi lần vào hàm. Nhưng cái tên của nó vẫn chỉ nhìn thấy được bên trong hàm.
Vậy static cho ta thời gian sống của biến toàn cục nhưng giữ nguyên phạm vi hẹp của biến cục bộ. Nó là công cụ đúng khi một hàm cần nhớ điều gì đó giữa các lần gọi. Cái giá phải trả là hàm không còn thuần nữa: gọi hai lần với cùng đầu vào có thể cho hai kết quả khác nhau, nên khó kiểm thử hơn. Dùng static khi việc nhớ trạng thái đúng là ý định của bạn, đừng dùng nó chỉ để né việc truyền tham số.
Thứ nhất, nhiều bạn tưởng biến cục bộ tự động bằng 0 khi khai báo. Không đúng: biến cục bộ chưa khởi tạo chứa giá trị rác. Chỉ biến toàn cục và biến static mới được tự động đặt về 0, vì chúng nằm ở vùng nhớ tĩnh được máy dọn sạch trước khi chương trình chạy. Đây là nguồn gốc của kiểu lỗi "chạy trên máy tôi thì đúng, trên máy bạn thì sai".
Thứ hai, khi một biến cục bộ trùng tên với một biến toàn cục, biến cục bộ che khuất biến toàn cục bên trong khối đó. Mã trông như đang sửa biến toàn cục nhưng thực ra chỉ sửa biến cục bộ. Cách tránh là đừng đặt trùng tên.
Thứ ba, trả về địa chỉ của một biến cục bộ là lỗi nghiêm trọng, vì biến ấy đã bị thu hồi ngay khi hàm kết thúc và con trỏ nhận được sẽ trỏ tới vùng nhớ không còn hiệu lực.
Bài tập thực hành
Bài tập 1: bốn biến cá nhân
Viết chương trình khai báo và khởi tạo bốn biến: một int chứa năm sinh, một float chứa chiều cao, một char chứa ký tự đầu của tên bạn, và một double chứa số dư tài khoản. In tất cả ra màn hình.
Gợi ý
Mỗi biến khai báo trên một dòng kèm giá trị ban đầu. Dùng %d cho int, %f cho float và double, %c cho char. Nhớ kết thúc mỗi câu lệnh bằng dấu chấm phẩy.
#include <stdio.h>
int main(void) {
int nam_sinh = 2005; // birth year
float chieu_cao = 1.68; // height in meters
char chu_dau = 'A'; // first letter of name
double so_du = 1500000.0; // account balance
printf("%d\n", nam_sinh);
printf("%.2f\n", chieu_cao);
printf("%c\n", chu_dau);
printf("%.2f\n", so_du);
return 0;
}
Bài tập 2: xem kích thước kiểu
Viết chương trình dùng sizeof để in kích thước theo byte của các kiểu int, float, double, char trên máy bạn. So sánh kết quả với bảng kích thước điển hình ở trên và nhận xét.
Gợi ý
Dùng lại đoạn mã sizeof trong bài, mỗi printf in một kiểu với định dạng %zu. Trên đa số máy hiện nay kết quả sẽ là char 1 byte, int 4 byte, float 4 byte, double 8 byte, đúng như bảng. Nếu máy bạn cho số khác, đó là do trình biên dịch hoặc kiến trúc khác.
Bài tập 3: hằng và chu vi hình tròn
Khai báo một hằng const float tên PI bằng 3.14, sau đó viết chương trình tính và in chu vi của một hình tròn bán kính 5 (chu vi bằng hai lần PI nhân bán kính). Quan sát giá trị in ra.
Gợi ý
Khai báo const float PI = 3.14; và float r = 5;, rồi tính float chu_vi = 2 * PI * r;. In bằng %.2f. Vì PI là hằng, mọi lệnh gán lại cho PI sẽ bị báo lỗi, đó chính là tác dụng bảo vệ của const.
#include <stdio.h>
int main(void) {
const float PI = 3.14; // named constant
float r = 5; // radius
float chu_vi = 2 * PI * r; // circumference
printf("Chu vi: %.2f\n", chu_vi);
return 0;
}
Bài tập 4: phân biệt ký tự và số
Giải thích bằng lời sự khác nhau giữa '5' và 5, giữa 'A' và "A". Cho ví dụ minh họa mỗi trường hợp dùng ở đâu.
Gợi ý
5 là một số nguyên, dùng trong tính toán như cộng trừ. '5' là một ký tự, lưu mã số của chữ số 5 theo bảng mã, dùng khi xử lý văn bản. 'A' là một ký tự đơn kiểu char, còn "A" là một chuỗi chứa ký tự A kèm dấu kết thúc chuỗi, nên hai thứ khác hẳn nhau về kiểu và cách dùng.
Bài tập 5: bẫy chia nguyên khi tính điểm trung bình
Viết chương trình nhận ba điểm số nguyên rồi in điểm trung bình với hai chữ số thập phân. Trước tiên hãy viết bản sai dùng phép chia nguyên, chạy thử với ba điểm 8, 9, 9 rồi giải thích vì sao kết quả ra 8.00 thay vì 8.67. Sau đó sửa lại cho đúng.
Lời giải chi tiết
Bản sai viết double tb = (a + b + c) / 3;. Cả tử và mẫu đều là int nên C dùng phép chia nguyên trước, cho ra 8 và vứt bỏ phần dư ngay tại chỗ. Chỉ sau đó con số 8 mới được kéo lên double để gán vào tb, nhưng lúc đó phần thập phân đã mất rồi. Ép kiểu ở vế trái không cứu được gì vì thiệt hại xảy ra bên trong biểu thức, trước khi phép gán diễn ra.
Cách sửa là làm cho phép chia trở thành phép chia thực ngay tại lúc chia, bằng cách ép một trong hai vế sang double. Chỉ cần ép một vế là đủ, vì C sẽ tự kéo vế còn lại lên theo.
#include <stdio.h>
int main(void) {
int a = 8, b = 9, c = 9;
double sai = (a + b + c) / 3; // integer division first: 8
double dung = (a + b + c) / 3.0; // 3.0 forces real division
double cung_dung = (double) (a + b + c) / 3; // cast the numerator instead
printf("sai = %.2f\n", sai); // 8.00
printf("dung = %.2f\n", dung); // 8.67
printf("cung dung= %.2f\n", cung_dung); // 8.67
return 0;
}
Ý chính: kiểu của các toán hạng quyết định phép toán nào được dùng, và quyết định ấy xảy ra ngay bên trong biểu thức chứ không đợi tới lúc gán.
Bài tập 6: hàm đếm số lần được gọi
Viết hàm void chao(void) in ra lời chào kèm số thứ tự của lần gọi, ví dụ lần đầu in "Xin chao, lan 1", lần sau in "Xin chao, lan 2". Không được dùng biến toàn cục và không được truyền tham số. Sau đó thử đổi static thành biến thường và giải thích kết quả.
Lời giải chi tiết
Ràng buộc "không toàn cục, không tham số" chỉ còn một lối đi là biến static. Hàm cần nhớ một con số qua các lần gọi, mà biến cục bộ thường thì bị thu hồi cùng khung ngăn xếp khi hàm kết thúc nên không nhớ được gì.
#include <stdio.h>
void chao(void) {
static int lan = 0; // lives in static storage, initialized only once
lan = lan + 1;
printf("Xin chao, lan %d\n", lan);
}
int main(void) {
chao(); // Xin chao, lan 1
chao(); // Xin chao, lan 2
chao(); // Xin chao, lan 3
return 0;
}
Nếu bỏ static đi, biến lan trở thành biến cục bộ thường. Mỗi lần vào hàm, máy cấp một ô nhớ mới trên ngăn xếp và chạy lại dòng khởi tạo = 0, nên lan luôn bắt đầu từ 0 và mọi lần gọi đều in ra "lan 1". Sự khác biệt nằm ở chỗ dòng khởi tạo của biến static chỉ chạy đúng một lần trước khi chương trình bắt đầu, còn dòng khởi tạo của biến thường chạy lại mỗi lần vào hàm.
Ý chính: static cho biến thời gian sống của biến toàn cục nhưng vẫn giữ phạm vi hẹp bên trong hàm.
Câu hỏi tự kiểm
- 1Cộng 1 vào một int đang giữ giá trị lớn nhất (INT_MAX) thì chuẩn C nói gì về kết quả?
- 2Với int a = 7, b = 2; biểu thức a / (double) b cho kết quả nào?
- 3Biến static khai báo trong một hàm khác biến cục bộ thường ở điểm nào?
- 4Một biến cục bộ chưa được khởi tạo chứa giá trị gì?
Tóm tắt
Kiểu là lời hướng dẫn ta gửi cho trình biên dịch về ba việc: dành bao nhiêu byte, hiểu chuỗi byte ấy theo quy tắc nào, và cho phép những phép toán gì. Bốn kiểu cơ bản chia làm hai nhóm với hai triết lý đối lập. Nhóm nguyên gồm int và char cho giá trị chính xác tuyệt đối nhưng phạm vi hẹp. Nhóm thực gồm float và double đổi độ chính xác lấy phạm vi rộng, nên số thực trong máy luôn là xấp xỉ và không bao giờ được so sánh bằng tuyệt đối.
Phạm vi hẹp của số nguyên dẫn tới tràn số. Với số nguyên có dấu, tràn là hành vi không xác định chứ không phải phép quay vòng được bảo đảm, nên không có "kết quả đúng" nào để trông đợi; chỉ số nguyên không dấu mới quay vòng theo đúng quy định của chuẩn. Đặc điểm nguy hiểm nhất của tràn là nó im lặng: không lỗi biên dịch, không sập chương trình, chỉ có một con số sai lặng lẽ lan ra toàn bộ tính toán phía sau. Tràn thường xảy ra ở các giá trị trung gian chứ không ở kết quả cuối, nên cách phòng vệ là kiểm tra trước khi tính, chẳng hạn hỏi a > INT_MAX - b trước khi cộng.
Khi trộn kiểu, C tự động ép kiểu hẹp lên kiểu rộng. Hướng đó an toàn, nhưng hướng ngược lại thì cắt bỏ chứ không làm tròn, và phép chia giữa hai số nguyên luôn là chia nguyên bất kể ta định gán kết quả vào đâu. Muốn có chia thực, phải ép kiểu trước khi chia chứ không phải sau.
Cuối cùng, mỗi biến có một phạm vi và một thời gian sống. Biến cục bộ hẹp và ngắn ngủi, đó là điểm mạnh vì nó thu hẹp danh sách nghi phạm khi đi tìm lỗi. Biến toàn cục rộng và bền, tiện nhưng dễ bị sửa từ khắp nơi. Biến static là kết hợp có chủ đích của hai thứ: sống bền như toàn cục mà chỉ hiện diện trong một hàm.
Mỗi biến phải được khai báo với một kiểu trước khi dùng; nên khởi tạo ngay để tránh giá trị rác. Biến là ô nhớ có tên mà nội dung đổi được; hằng const giữ giá trị cố định và được trình biên dịch bảo vệ khỏi việc gán lại.