BÀI 3. CÁC THÀNH PHẦN CƠ BẢN CỦA NGÔN NGỮ C
Mục tiêu
Sau bài này, học viên:
- Tách được một chương trình thành đơn vị từ vựng: từ khóa, định danh, hằng, toán tử, dấu phân cách.
- Chọn được kiểu dữ liệu phù hợp và biết kích thước kiểu không phải chân lý vũ trụ mà là hợp đồng của chuẩn cộng với trình biên dịch.
- Khai báo, khởi tạo và sử dụng biến; phân biệt được khai báo và phép gán.
- Viết được biểu thức đúng độ ưu tiên, đúng kiểu, và đọc được chuyển kiểu ngầm định.
- Dùng được
printf/scanfđúng đặc tả chuyển đổi, kiểm tra giá trị trả về củascanf. - Hiểu con trỏ từ mô hình bộ nhớ: biến nằm ở một địa chỉ,
&lấy địa chỉ,*đi vào địa chỉ đó để đọc hoặc ghi. Đọc xong phần này, người chưa từng thấy con trỏ vẫn giải thích được vì saoscanfcần dấu&. - Ở mức cao thủ: giải thích được thúc đẩy kiểu nguyên, tràn số có dấu là hành vi không xác định, số thực nhị phân không biểu diễn được mọi số thập phân, con trỏ
NULL, con trỏ hằng, và vì sao so sánh==vớifloatlà bẫy.
Bài này chưa lấy rẽ nhánh và vòng lặp làm chủ đề. Một vài if chỉ để bảo vệ nhập liệu, đã gặp ở Bài 1.
1. Bộ từ vựng
Chương trình C là dòng ký tự được tách thành token. Khoảng trắng ngăn token, không có nghĩa tính toán, trừ khi nằm trong chuỗi hoặc hằng ký tự.
1.1. Từ khóa
Từ khóa là tên ngôn ngữ đã giữ. Không được dùng làm tên biến. Bộ cốt lõi cần thuộc trong khóa này:
auto, break, case, char, const, continue, default, do, double, else, enum, extern, float, for, goto, if, int, long, register, return, short, signed, sizeof, static, struct, switch, typedef, union, unsigned, void, volatile, while.
C99 thêm inline, restrict, _Bool, _Complex, _Imaginary. C11 thêm _Alignas, _Alignof, _Atomic, _Generic, _Noreturn, _Static_assert, _Thread_local.
Các từ khóa hay bị liệt kê rồi bỏ quên, nghĩa dùng trong khóa này:
| Từ khóa | Nghĩa ngắn | Bài dùng sâu |
|---|---|---|
auto | Biến cục bộ thông thường. Không cần viết, vì đây là mặc định | 6 |
register | Gợi ý giữ biến trong thanh ghi. Trình biên dịch được phép bỏ qua. Không lấy địa chỉ của biến register | 3 |
extern | Tên này được định nghĩa ở file khác. Chỉ là khai báo, không tạo ô nhớ mới | 6 |
static | Kéo thời gian sống ra cả chương trình, hoặc giới hạn tên trong một file | 6 |
volatile | Giá trị có thể đổi ngoài chương trình, như thanh ghi phần cứng. Cấm trình biên dịch tối ưu bỏ lần đọc | 3 |
goto nhãn; | Nhảy tới một nhãn trong cùng hàm. Phá cấu trúc tuần tự, rẽ nhánh, lặp. Khóa này không dùng goto để giải bài | 4 |
sizeof | Toán tử hỏi kích thước theo byte | 3 |
typedef | Đặt tên mới cho một kiểu | 8 |
inline chỉ là gợi ý chép thân hàm vào chỗ gọi. Không đổi nghĩa chương trình.
main không phải từ khóa. Nó là tên hàm đặc biệt theo quy ước môi trường thực thi.
1.2. Định danh (tên)
Tên gồm chữ, chữ số và _, không bắt đầu bằng chữ số. C phân biệt hoa thường: Tong và tong là hai tên. Độ dài nhận diện tối thiểu theo chuẩn là 31 ký tự cho tên nội bộ; đừng dựa vào việc tên dài 200 ký tự luôn khác nhau trên mọi trình biên dịch cổ.
Quy ước của khóa học: snake_case cho biến và hàm, tên nói dữ liệu chứ không nói kiểu (diem_trung_binh, không float1).
1.3. Chú thích
/* Chú thích nhiều dòng, không lồng nhau */
// Chú thích đến hết dòng, từ C99
Chú thích không đến được trình biên dịch ngữ nghĩa. Đừng để chú thích nói một đằng, code một nẻo.
2. Kiểu dữ liệu cơ bản
Kiểu cho biết: tập giá trị, cách mã hóa trong bộ nhớ, và phép toán hợp lệ.
| Kiểu | Ý nghĩa | Kích thước tối thiểu theo chuẩn |
|---|---|---|
char | Ký tự / số nguyên rất nhỏ | 8 bit |
short | Số nguyên ngắn | 16 bit |
int | Số nguyên “tự nhiên” của máy | 16 bit, thực tế 32 bit trên máy học |
long | Số nguyên dài | 32 bit |
long long | Số nguyên rất dài (C99) | 64 bit |
float | Số thực chính xác đơn | thường 32 bit IEEE 754 |
double | Số thực chính xác kép | thường 64 bit IEEE 754 |
long double | Rộng hơn hoặc bằng double | phụ thuộc trình biên dịch |
_Bool / bool | 0 hoặc 1 | thường 1 byte |
sizeof(char) luôn là 1. Các kiểu khác đo bằng đơn vị char. Trên LP64 (macOS, Linux 64-bit): sizeof(int) là 4, sizeof(long) là 8, sizeof(long long) là 8. Trên Windows 64-bit (LLP64): sizeof(long) vẫn là 4. Đừng viết thuật toán phụ thuộc “int luôn 4 byte, long luôn 8 byte”. Hãy dùng sizeof và các hằng trong <limits.h>, <float.h>.
char có thể signed hoặc unsigned tùy trình biên dịch. Khi lưu mã 0..255, hãy ghi unsigned char.
Số có dấu dùng biểu diễn bù hai trên gần như mọi máy hiện nay. Miền int 32 bit là từ -2147483648 đến 2147483647.
Tràn số có dấu (int vượt miền) là hành vi không xác định. Tràn số không dấu được định nghĩa: quay vòng theo modulo 2^số_bit.
Số thực float/double là số nhị phân có số mũ. Chúng không phải số thập phân vô hạn chính xác. 0.1 không có biểu diễn double hữu hạn đúng tuyệt đối. Sai số nhỏ là bình thường; so sánh bằng == là không bình thường.
3. Biến
Biến là tên gắn với một vùng nhớ giữ giá trị có kiểu, và giá trị đó thay đổi được trong đời sống của biến.
int so_luong; /* khai báo, giá trị chưa xác định */
int so_luong = 0; /* định nghĩa kèm khởi tạo */
so_luong = 12; /* phép gán */
Đọc biến chưa khởi tạo là hành vi không xác định. Hãy khởi tạo tại chỗ khai báo nếu đã biết giá trị.
const int gioi_han = 100; không biến vùng nhớ thành hằng vật lý theo nghĩa mọi trình biên dịch, nhưng cấm gán qua tên đó. Muốn hằng thật sự trong biểu thức hằng, Bài này dùng thêm #define hoặc enum (enum học kỹ cùng cấu trúc ở Bài 8; ở đây chỉ cần biết hằng số nguyên).
Phân biệt:
#define TEN 10 | const int ten = 10; | |
|---|---|---|
| Bản chất | Thay văn bản trước khi biên dịch | Đối tượng có kiểu |
| Kiểu | Không có | Có |
| Gỡ lỗi | Tên có thể biến mất | Tên còn trong debugger |
4. Hằng
Số nguyên
10 thập phân, 010 bát phân (giá trị 8, bẫy kinh điển), 0x1A thập lục phân (26), 0b1010 nhị phân từ C23 (đừng dùng nếu dịch với C11). Hậu tố: u/U unsigned, l/L long, ll/LL long long. 10000000000 có thể không vừa int; viết 10000000000LL.
Số thực
3.14, 2.5e-3 nghĩa là 0.0025. Hằng thực mặc định kiểu double. Hậu tố f cho float: 3.14f.
Ký tự và chuỗi
'A' là hằng ký tự, kiểu int trong biểu thức. "A" là chuỗi hai byte: 'A' và '\0'. Nhầm hai thứ này là lỗi rất hay gặp từ Bài 7 trở đi; hãy khắc ngay.
Chuỗi rỗng "" vẫn có một byte '\0'. Escape thường dùng: \n xuống dòng, \t tab, \\ dấu \, \' nháy đơn, \" nháy kép, \0 ký tự mã 0.
5. Biểu thức, phép toán, độ ưu tiên
Biểu thức là tổ hợp toán hạng và toán tử cho ra một giá trị. 3 + 7, 2 * y + 5, a >= 0 đều là biểu thức. Một biểu thức kèm ; có thể thành câu lệnh.
Nhóm toán tử
| Nhóm | Toán tử | Ghi chú |
|---|---|---|
| Số học | + - * / % | % chỉ cho số nguyên. Chia hai int là chia nguyên |
| Quan hệ | < > <= >= | Kết quả 0 hoặc 1, kiểu int |
| Bằng | == != | = là gán, không phải so sánh |
| Logic | ! && || | Đoản mạch: && dừng nếu vế trái sai, || dừng nếu vế trái đúng |
| Bit | ~ & | ^ << >> | Làm trên từng bit. Khác logic |
| Gán | = += -= *= /= %= | Gán là biểu thức, giá trị là giá trị vừa gán |
| Tăng giảm | ++ -- | Tiền tố đổi rồi cho giá trị mới; hậu tố cho giá trị cũ rồi đổi |
| Khác | sizeof, (kieu), ?:, dấu phẩy | sizeof là toán tử, không phải hàm |
Độ ưu tiên thực hành, từ cao xuống thấp, bản rút gọn đủ dùng:
()và hậu tố++ --- Một ngôi:
++ -- ! ~ + - (ép kiểu) sizeof * / %+ -<< >>< <= > >=== !=&rồi^rồi|&&rồi||?:- Gán
- Dấu phẩy
Cùng mức thì hầu hết kết hợp trái sang phải; gán và ?: kết hợp phải sang trái.
Ví dụ kinh điển:
3 * 5 + 4= 196 + 2 / 2 - 3= 44 > 10có giá trị 04 >= 4có giá trị 16 - 3 < 4tương đương(6 - 3) < 4, giá trị 1
Hình dưới đây là cùng một biểu thức 6 + 2 / 2 - 3, làm theo tầng ưu tiên. Nhìn từ trên xuống, đừng nhìn từ trái sang phải một lượt.
6 + 2 / 2 - 3
│
▼ chia trước cộng và trừ
6 + 1 - 3
│
▼ cùng mức, trái sang phải
7 - 3
│
▼
4
Một hình thứ hai cho chỗ hay sai nhất, ép kiểu:
(double) a / b (double)(a / b)
a đổi sang thực a và b còn nguyên
rồi mới chia chia nguyên ra 3
ra 3.5 rồi mới đổi thành 3.0
Chia nguyên: 7 / 2 là 3 nếu cả hai toán hạng là int. 7.0 / 2 là 3.5. Dư: 7 % 2 là 1. Dấu của % khi số âm là chỗ dễ lệch giữa các chuẩn cũ; trong C99 trở đi, thương tiến về 0, nên (-7) / 2 là -3 và (-7) % 2 là -1.
Ép kiểu tường minh: (double) a / b ép a trước khi chia. (double)(a / b) chia nguyên trước rồi mới đổi sang thực — thường là sai ý định.
Chuyển đổi thường gặp. Khi int gặp double trong phép số học, int được đổi thành double. Trong biểu thức nguyên, các toán hạng hẹp hơn int (char, short) được thúc đẩy lên int trước. Đó là integer promotion.
++. Đừng viết a = a++ hay printf("%d %d", i, i++). Chuẩn không xếp thứ tự các hiệu ứng phụ này trong cùng một biểu thức không có điểm tuần tự rõ. Đó là hành vi không xác định. Mỗi câu lệnh một việc.
6. Nhập và xuất
printf
printf("định dạng", các giá trị);
Đặc tả hay dùng:
| Ký hiệu | Kiểu đối số |
|---|---|
%d | int thập phân |
%u | unsigned int |
%ld, %lld | long, long long |
%f | double khi printf (float được thúc đẩy thành double) |
%e | dạng khoa học |
%.2f | hai chữ số sau dấu phẩy khi in |
%c | ký tự |
%s | chuỗi |
%p | con trỏ, in địa chỉ; xem mục 7 của bài này |
%% | in một dấu % |
Sai đặc tả so với kiểu đối số là hành vi không xác định: in rác hoặc tệ hơn. printf trả về số ký tự đã in, hoặc âm nếu lỗi.
scanf
scanf("định dạng", &biến, ...);
scanf ghi vào biến, nên cần địa chỉ. Nó trả về số mục gán thành công, hoặc EOF khi hết input.
if (scanf("%d", &n) != 1) {
printf("Du lieu khong phai so nguyen.\n");
return 1;
}
Khoảng trắng trong chuỗi định dạng khớp mọi khoảng trắng trên input. %d tự bỏ khoảng trắng đầu. %c thì không. Dòng nhập còn ký tự thừa sẽ nằm lại trong bộ đệm stdin và làm lần scanf sau đọc phải chúng. Khi bài tập “nhập số rồi nhập ký tự” bị nhảy, hãy nghi bộ đệm trước khi nghi máy hỏng.
%f của scanf cần float *. %lf cần double *. Đây là chỗ printf và scanf không đối xứng: printf("%f", bien_double) vẫn đúng vì đối số float được thúc đẩy, còn scanf("%f", &bien_double) là sai.
Đọc và in một ký tự, không qua định dạng nhiều mục:
| Hàm | Việc |
|---|---|
getchar() | Đọc một ký tự từ stdin, trả về mã int, hoặc EOF khi hết |
putchar(c) | In một ký tự |
puts(s) | In một chuỗi rồi tự xuống dòng |
gets | Không dùng. Không có giới hạn độ dài, tràn bộ đệm |
EOF là hằng báo hết luồng nhập, không phải một ký tự người dùng gõ được một cách thông thường. So getchar() với EOF bằng int, đừng nhét ngay vào char vì EOF có thể nằm ngoài miền của char có dấu.
Toán tử phẩy a, b tính a, bỏ giá trị đó, rồi tính b và lấy giá trị của b. Nó đứng cuối bảng ưu tiên. Đừng dùng trong bài giải để nhồi hai việc vào một biểu thức. Chỗ duy nhất hay gặp là ngăn bước của for cũ. Khóa này mỗi câu lệnh một việc.
Dấu & trong scanf("%d", &n) không phải nghi thức. Nó là địa chỉ của n. Phần tiếp theo giải thích địa chỉ đó là gì.
7. Con trỏ: bộ nhớ, địa chỉ, và hai toán tử & với *
Con trỏ không phải mẹo, không phải chương sau. Con trỏ là cách C gọi địa chỉ của một ô nhớ. Nếu hiểu ô nhớ, bạn hiểu con trỏ. Nếu học thuộc cú pháp mà không hình dung ô nhớ, mọi bài sau sẽ rối.
7.1. Bộ nhớ là một dãy ô được đánh số
Khi chương trình chạy, hệ điều hành cấp cho tiến trình một vùng nhớ. Hãy hình dung một dãy tủ, mỗi ngăn một byte. Mỗi ngăn có một số nhà gọi là địa chỉ. Địa chỉ là một số nguyên không âm, rất lớn, thường viết dạng thập lục phân như 0x7ffeee20.
Một biến int trên máy 4 byte chiếm bốn ngăn liền nhau. Tên biến là nhãn dán lên ngăn đầu. Giá trị của biến nằm trong các ngăn đó.
Địa chỉ (ví dụ) Ngăn nhớ Tên
0x1000 [ 0A ] ┐
0x1001 [ 00 ] │ bốn byte này là một int
0x1002 [ 00 ] │ tên của cụm này là tuoi
0x1003 [ 00 ] ┘ giá trị hiện tại: 10
0x1004 [ .. ] biến khác
Ba câu phải tách bạch, vì trộn chúng là nguồn mọi nhầm lẫn:
| Câu | Nghĩa | Ví dụ |
|---|---|---|
| Giá trị | Thứ nằm trong biến | tuoi đang là 10 |
| Địa chỉ | Số nhà của ngăn đầu | &tuoi là 0x1000 |
| Tên | Nhãn trong mã nguồn | chữ tuoi |
Tên chỉ tồn tại khi biên dịch và khi gỡ lỗi. Lúc chạy, CPU làm việc bằng địa chỉ.
7.2. Toán tử &: hỏi số nhà
&tuoi đọc là “địa chỉ của tuoi”. Nó không lấy số tuổi. Nó lấy số nhà của ô đang giữ số tuổi.
int tuoi = 10;
printf("gia tri = %d\n", tuoi);
printf("dia chi = %p\n", (void *)&tuoi);
%p là đặc tả in địa chỉ. Ép (void *) là cách viết chuẩn khi in con trỏ bất kỳ. Kết quả mỗi lần chạy có thể khác nhau vì hệ điều hành đặt biến ở chỗ khác; điều đó bình thường. Bạn cần thấy có một số nhà, không cần học thuộc số đó.
& chỉ áp lên một lvalue: thứ có ô nhớ thật, như biến, phần tử mảng, thành viên struct. &10 vô nghĩa vì số 10 không có nhà. &(a + b) vô nghĩa vì tổng là giá trị tạm, không phải ô nhớ đặt tên (trừ một số ngoại lệ rất hẹp của chuẩn mà khóa này không dựa vào).
7.3. Biến con trỏ: một biến mà giá trị của nó là một địa chỉ
Con trỏ là biến bình thường. Điều khác biệt duy nhất: giá trị nó giữ không phải tuổi, điểm, hay tiền, mà là địa chỉ của một biến khác.
int tuoi = 10;
int *p = &tuoi;
Đọc khai báo từ tên ra ngoài, đây là quy tắc cả đời:
plà tên.*pnghĩa là “thứ màptrỏ tới”.int *pnghĩa là “*pcó kiểuint”, nênplà con trỏ tớiint.
Hình:
p (kiểu int *) tuoi (kiểu int)
┌──────────────┐ ┌──────────────┐
│ 0x1000 │ ──────────► │ 10 │
│ địa chỉ của │ │ giá trị │
│ tuoi │ └──────────────┘
└──────────────┘
nhà của p: 0x2000 nhà của tuoi: 0x1000
p và tuoi là hai ô nhớ khác nhau. p chứa số 0x1000. tuoi chứa số 10.
Dấu sao trong khai báo int *p và dấu sao trong biểu thức *p cùng một ký tự, hai vai trò:
| Chỗ viết | Vai trò |
|---|---|
int *p | Khai báo: p là con trỏ tới int |
*p trong biểu thức | Đi theo địa chỉ và lấy ô ở đó |
Viết int* p hay int *p đều được. Khóa này viết int *p để sao nằm sát tên, nhắc “sao gắn với tên”. Cảnh báo: int *p, q; nghĩa là p là con trỏ, q là int thường. Muốn hai con trỏ thì int *p, *q;. An toàn nhất: mỗi con trỏ một dòng khai báo.
7.4. Toán tử *: đi vào số nhà và mở tủ
Nếu p đang giữ địa chỉ của tuoi thì *p chính là tuoi.
printf("%d\n", *p); /* in 10, đọc qua địa chỉ */
*p = 21; /* ghi 21 vào đúng ô của tuoi */
printf("%d\n", tuoi); /* in 21 */
*p = 21 không đổi số nhà nằm trong p. Nó đổi nội dung ngôi nhà mà p chỉ tới. Sau phép gán, p vẫn là 0x1000, tuoi thành 21.
Hai chiều cần thuộc đến mức không nghĩ:
& đi từ biến → ra địa chỉ (&tuoi cho ra một int *)
* đi từ địa chỉ → vào biến (*p cho ra một int)
Chúng nghịch nhau khi dùng đúng:
int tuoi = 10;
int *p = &tuoi;
/* *(&tuoi) chính là tuoi */
/* &(*p) chính là p, khi p đang trỏ tới một biến thật */
7.5. Vì sao scanf cần &
scanf là một hàm. Hàm C nhận bản sao giá trị (Bài 6 sẽ nói kỹ hợp đồng này; ở đây chỉ cần một sự thật). Nếu gọi scanf("%d", n) thì hàm chỉ nhận bản sao của số đang nằm trong n. Nó có thể sửa bản sao, rồi bản sao biến mất. n của bạn không đổi.
scanf cần chỗ để ghi. Chỗ để ghi chính là địa chỉ. scanf("%d", &n) đưa số nhà của n vào hàm. Bên trong, hàm làm tương đương *địa_chỉ = số_vừa_đọc.
Bạn scanf
n = ??? ←── ghi vào đúng ô này, vì được đưa &n
printf("%d", n) chỉ cần đọc giá trị, nên đưa n, không đưa &n. Đưa nhầm &n cho %d là đưa một địa chỉ vào chỗ đang chờ một int: hành vi không xác định.
Quy tắc một dòng:
printfmột số: đưa giá trị, không có&.scanfmột số: đưa địa chỉ, có&.
7.6. NULL: con trỏ cố ý không trỏ đi đâu
NULL là một giá trị địa chỉ đặc biệt, nghĩa là “không trỏ tới đối tượng nào”. Khai báo trong <stddef.h>, và thường có sẵn qua <stdio.h>.
int *p = NULL;
*p khi p là NULL là hành vi không xác định. Trên nhiều máy, hệ điều hành dừng chương trình. Hãy kiểm tra trước khi đi theo con trỏ:
if (p != NULL) {
printf("%d\n", *p);
}
Con trỏ chưa khởi tạo còn nguy hiểm hơn NULL. Nó chứa một địa chỉ rác. Đi theo địa chỉ rác có thể ghi vào bộ nhớ của chính chương trình. Luôn khởi tạo con trỏ bằng &biến hoặc bằng NULL ngay khi khai báo.
7.7. Một con trỏ chỉ nên trỏ tới đúng kiểu
int * trỏ tới int. double * trỏ tới double. Gán lẫn hai kiểu này mà không ép kiểu là lỗi. Lý do thực: *p phải biết đọc mấy byte và đọc theo cách nào. int 4 byte và double 8 byte không phải cùng một cách đọc.
int tuoi = 10;
int *p = &tuoi; /* đúng */
/* double *q = &tuoi; sai kiểu */
Ép (double *) &tuoi rồi đọc *q là hành vi không xác định. Đừng làm để “xem máy in ra gì”.
7.8. Con trỏ hằng và hằng con trỏ
Đọc từ phải sang trái, hoặc nhớ vị trí const:
int tuoi = 10;
int diem = 9;
const int *p = &tuoi; /* được đổi p sang nhà khác, không được *p = ... */
int *const q = &tuoi; /* q kẹt tại nhà tuoi, nhưng *q = 11 được */
const int *const r = &tuoi; /* không đổi r, không đổi qua *r */
const int *p là hợp đồng “nhìn nhưng không ghi”. Hàm sau này nhận const int * để người gọi yên tâm dữ liệu không bị sửa. int *const q ít gặp hơn: chính biến con trỏ bị khóa.
7.9. Sai lầm kinh điển
| Việc viết | Chuyện thật sự xảy ra |
|---|---|
int *p; *p = 5; | p chưa trỏ đi đâu, ghi vào địa chỉ rác |
int *p = 5; | gán số 5 như thể nó là địa chỉ, không phải gán giá trị 5 cho một int |
scanf("%d", p) khi p chưa trỏ vào một int hợp lệ | scanf ghi vào nhà không tồn tại |
printf("%d", p) | in địa chỉ bằng đặc tả của số nguyên, sai kiểu |
*p = *p + 1 khi p == NULL | đi vào nhà số 0 |
Nhầm *p ở khai báo và *p ở biểu thức | quên mất khai báo chỉ nói kiểu |
Muốn “một con trỏ tới số 5”, không có số 5 nào để trỏ tới trừ khi số 5 đang nằm trong một biến:
int x = 5;
int *p = &x;
7.10. Con trỏ và hàm, mức vừa đủ để dùng ngay
Bạn được phép viết hàm nhận con trỏ ngay trong bài này. Ý nghĩa chỉ có một: người gọi đưa số nhà, hàm ghi hoặc đọc ngôi nhà đó.
void gan_hai_muoi(int *o_nho)
{
if (o_nho == NULL) {
return;
}
*o_nho = 20;
}
Gọi: gan_hai_muoi(&tuoi);. Sau lời gọi, tuoi là 20. Hàm không nhận bản sao của tuổi. Hàm nhận bản sao của địa chỉ, rồi dùng địa chỉ đó để sửa ô gốc. Bản sao địa chỉ vẫn chỉ cùng một nhà.
Chưa cần con trỏ tới con trỏ, chưa cần cấp phát động. Hai thứ đó xuất hiện khi mảng và danh sách cần vùng nhớ xin lúc chạy (Bài 7). Nền để hiểu chúng là sáu chữ: biến có nhà, con trỏ giữ số nhà, sao mở nhà.
7.11. Bài tập giấy trước khi gõ
Lấy int a = 3; int b = 8; int *p = &a;. Điền bảng sau mỗi dòng, đừng chạy máy:
- Ban đầu:
a,b,*p. - Sau
*p = *p + 1. - Sau
p = &b. - Sau
*p = a. - Sau
a = 100. Hỏibcó đổi không, và vì sao.
Đáp:
a = 3,b = 8,*p = 3vìpđang chỉa.a = 4,b = 8,*p = 4.pchỉb.avẫn 4.*plà 8.*p = aghi 4 vàob.b = 4.avẫn 4.a = 100không đụng ô củab.bvẫn 4.pvẫn chỉb, nên*pvẫn 4.
Nếu bạn giải được năm dòng này trên giấy, bạn đã hiểu con trỏ ở mức dùng được. Phần ví dụ phía dưới chỉ là cùng mô hình đó viết thành chương trình.
8. Ví dụ
Ví dụ 3.1 — Bảng sizeof trên chính máy bạn
Phân tích. In kích thước và miền giá trị thay vì học thuộc một bảng trên giấy. Chương trình này là dụng cụ, sẽ dùng lại mỗi khi đổi máy.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
#include <limits.h>
#include <float.h>
int main(void)
{
printf("char %zu\n", sizeof(char));
printf("short %zu\n", sizeof(short));
printf("int %zu\n", sizeof(int));
printf("long %zu\n", sizeof(long));
printf("long long %zu\n", sizeof(long long));
printf("float %zu\n", sizeof(float));
printf("double %zu\n", sizeof(double));
printf("INT_MIN %d\n", INT_MIN);
printf("INT_MAX %d\n", INT_MAX);
printf("UINT_MAX %u\n", UINT_MAX);
printf("FLT_DIG %d\n", FLT_DIG);
printf("DBL_DIG %d\n", DBL_DIG);
return 0;
}
Kết quả điển hình trên macOS 64-bit (số sizeof(long) có thể khác Windows):
char 1
short 2
int 4
long 8
long long 8
float 4
double 8
INT_MIN -2147483648
INT_MAX 2147483647
UINT_MAX 4294967295
FLT_DIG 6
DBL_DIG 15
Phân tích chi tiết. %zu là đặc tả cho kiểu size_t mà sizeof trả về. Dùng %d cho sizeof là sai kiểu trên máy 64-bit. FLT_DIG là số chữ số thập phân đáng tin của float, khoảng 6. Đừng in tiền tệ bằng float.
Ví dụ 3.2 — Máy tính bốn phép trên số nguyên
Phân tích. Đọc hai int và một phép + - * / %. In kết quả. Với / và %, từ chối chia cho 0. Bài này cố ý dùng if tối thiểu vì chia cho 0 không phải là một giá trị biểu thức hợp lệ.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
int a;
int b;
char phep;
int ket_qua;
printf("Nhap a phep b, vi du 7 / 2: ");
if (scanf("%d %c %d", &a, &phep, &b) != 3) {
printf("Sai dinh dang.\n");
return 1;
}
if ((phep == '/' || phep == '%') && b == 0) {
printf("Khong chia cho 0.\n");
return 1;
}
if (phep == '+') {
ket_qua = a + b;
} else if (phep == '-') {
ket_qua = a - b;
} else if (phep == '*') {
ket_qua = a * b;
} else if (phep == '/') {
ket_qua = a / b;
} else if (phep == '%') {
ket_qua = a % b;
} else {
printf("Phep khong ho tro.\n");
return 1;
}
printf("%d %c %d = %d\n", a, phep, b, ket_qua);
return 0;
}
Kết quả.
Nhap a phep b, vi du 7 / 2: 7 / 2
7 / 2 = 3
Nhap a phep b, vi du 7 / 2: 7 % 2
7 % 2 = 1
Phân tích chi tiết. 7 / 2 ra 3 vì cả hai là int. Nếu đề bài muốn 3.5, kiểu phải là double và đặc tả in là %f. Điều kiện chặn 0 đứng trước phép chia: đoản mạch của && không cần ở đây, nhưng thứ tự kiểm tra thì cần. Biểu thức a + b với a và b sát INT_MAX có thể tràn. Bản cao thủ sẽ tính trong long long rồi mới quyết định có in được bằng int hay không.
Ví dụ 3.3 — Ép kiểu đúng chỗ
Phân tích. Tính trung bình cộng của hai điểm nguyên. Yêu cầu một chữ số lẻ không bị cắt. (a + b) / 2 sai ý vì chia nguyên. (double)(a + b) / 2 đúng. (double) a / 2 + b / 2 vẫn cắt b / 2.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
int diem_1;
int diem_2;
double trung_binh;
printf("Hai diem: ");
if (scanf("%d %d", &diem_1, &diem_2) != 2) {
return 1;
}
trung_binh = (double)(diem_1 + diem_2) / 2.0;
printf("Trung binh = %.2f\n", trung_binh);
return 0;
}
Kết quả với 7 và 8.
Hai diem: 7 8
Trung binh = 7.50
Phân tích chi tiết. Nếu bỏ ép kiểu, 15 / 2 ra 7, in thành 7.00, sai đề. Ép kiểu là một toán tử có độ ưu tiên cao, nên dấu ngoặc quanh tổng là có chủ đích: muốn đổi cả tổng, không chỉ đổi số đầu.
Ví dụ 3.4 — Mặt nạ bit (mức cao thủ của toán tử)
Phân tích. Một cảm biến đóng gói cờ trong một byte: bit 0 là “có khói”, bit 1 là “có chuyển động”, bit 2 là “pin yếu”. Cần bật, tắt và kiểm tra từng cờ mà không làm hỏng cờ khác. Đây là việc của phép bit, không phải phép logic &&.
Quy ước: 1u << k là mặt nạ có đúng bit k bật.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
unsigned int co = 0u;
const unsigned int KHOI = 1u << 0;
const unsigned int CHUYEN_DONG = 1u << 1;
const unsigned int PIN_YEU = 1u << 2;
co = co | KHOI;
co = co | PIN_YEU;
if ((co & KHOI) != 0u) {
printf("Co khoi\n");
}
if ((co & CHUYEN_DONG) == 0u) {
printf("Khong co chuyen dong\n");
}
co = co & ~PIN_YEU;
printf("Trang thai = 0x%X\n", co);
return 0;
}
Kết quả.
Co khoi
Khong co chuyen dong
Trang thai = 0x1
Phân tích chi tiết.
co | KHOIbật bit mà không xóa bit khác.co & KHOItách một bit để kiểm tra. So với 0, đừng viếtif (co & KHOI == 0)vì==ưu tiên hơn&, biểu thức thànhco & (KHOI == 0)và sai nghĩa.~PIN_YEUđảo mọi bit của mặt nạ; AND với nó tắt đúng bit pin yếu. Kết quả còn bit 0, giá trị 1, in hexa là0x1.- Dùng
unsignedcho phép bit. Phép dịch bit trên số có dấu, hoặc dịch một lượng âm, hoặc dịch quá bề rộng, là vùng nguy hiểm của chuẩn.
Ví dụ 3.5 — Số thực không phải số thập phân
Phân tích. Cộng 0.1 mười lần vào float và vào double, so với 1. Mục tiêu là thấy sai số, không phải “máy tính kém”.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
float sf = 0.0f;
double sd = 0.0;
int i;
for (i = 0; i < 10; i = i + 1) {
sf = sf + 0.1f;
sd = sd + 0.1;
}
printf("float = %.10f\n", sf);
printf("double = %.20f\n", sd);
printf("float == 1? %d\n", sf == 1.0f);
printf("double == 1? %d\n", sd == 1.0);
return 0;
}
Kết quả điển hình.
float = 1.0000001192
double = 1.00000000000000022204
float == 1? 0
double == 1? 0
Phân tích chi tiết. 0 trong kết quả so sánh nghĩa là sai. Muốn biết hai số thực “gần bằng nhau”, hãy so hiệu trị tuyệt đối với một epsilon chọn theo bài toán, ví dụ 1e-6 cho tiền bậc trung học, hoặc một sai số tương đối khi số rất lớn. Epsilon không phải hằng vũ trụ. Vòng for ở đây chỉ là công cụ lặp 10 lần; cấu trúc vòng lặp được học đầy đủ ở Bài 5.
Ví dụ 3.6 — Nhìn giá trị và nhìn địa chỉ
Phân tích. Tạo một int, một con trỏ trỏ tới nó, in giá trị, địa chỉ của biến, địa chỉ đang nằm trong con trỏ, và giá trị đọc qua con trỏ. Sau đó ghi qua con trỏ và in lại biến gốc. Đây là chương trình đối chiếu với hình ô nhớ ở mục 7.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
int tuoi = 10;
int *p = &tuoi;
printf("gia tri tuoi = %d\n", tuoi);
printf("dia chi tuoi = %p\n", (void *)&tuoi);
printf("gia tri cua p = %p\n", (void *)p);
printf("gia tri tai *p = %d\n", *p);
*p = 21;
printf("sau khi ghi qua p, tuoi = %d\n", tuoi);
printf("sau khi ghi qua p, *p = %d\n", *p);
return 0;
}
Kết quả (địa chỉ trên máy bạn sẽ khác; hai dòng địa chỉ phải trùng nhau).
gia tri tuoi = 10
dia chi tuoi = 0x7ff7b2c4a1c
gia chi cua p = 0x7ff7b2c4a1c
gia tri tai *p = 10
sau khi ghi qua p, tuoi = 21
sau khi ghi qua p, *p = 21
Phân tích chi tiết. Hai dòng %p in cùng một số vì p được gán bằng &tuoi. *p = 21 không đổi p. Nó đổi ô mà p chỉ. Vì ô đó chính là tuoi, tên tuoi và biểu thức *p là hai cách gọi cùng một ngăn. Nếu hai địa chỉ in ra khác nhau, phép gán p = &tuoi đã không còn trong chương trình.
Ví dụ 3.7 — Đổi chỗ con trỏ chỉ, không đổi nhầm ô
Phân tích. Có a = 3, b = 8, p ban đầu chỉ a. Thực hiện đúng năm bước của bài tập giấy 7.11 và in sau mỗi bước. Mục tiêu là thấy sự khác nhau giữa p = &b (đổi số nhà trong con trỏ) và *p = ... (đổi nội dung ngôi nhà).
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
int a = 3;
int b = 8;
int *p = &a;
printf("1) a=%d b=%d *p=%d\n", a, b, *p);
*p = *p + 1;
printf("2) a=%d b=%d *p=%d\n", a, b, *p);
p = &b;
printf("3) a=%d b=%d *p=%d\n", a, b, *p);
*p = a;
printf("4) a=%d b=%d *p=%d\n", a, b, *p);
a = 100;
printf("5) a=%d b=%d *p=%d\n", a, b, *p);
return 0;
}
Kết quả.
1) a=3 b=8 *p=3
2) a=4 b=8 *p=4
3) a=4 b=8 *p=8
4) a=4 b=4 *p=4
5) a=100 b=4 *p=4
Phân tích chi tiết. Bước 2 sửa a vì p còn chỉ a. Bước 3 không sửa số nào; chỉ đổi hướng của p, nên *p đột ngột thành 8. Bước 4 ghi giá trị của a (đang là 4) vào ô b. Bước 5 sửa a bằng tên của a. b không liên quan, p vẫn chỉ b, nên *p vẫn 4. Đây là bài phải đối chiếu từng dòng với hình hai ngôi nhà trước khi tin máy.
Ví dụ 3.8 — Hàm ghi vào ô nhớ người gọi đưa địa chỉ
Phân tích. Viết dat_gia_tri nhận địa chỉ một int và một số cần ghi. Nếu con trỏ là NULL, không ghi. main gọi với &diem. In điểm trước và sau để thấy ô gốc đổi.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
void dat_gia_tri(int *o, int gia_tri)
{
if (o == NULL) {
return;
}
*o = gia_tri;
}
int main(void)
{
int diem = 0;
printf("Truoc: %d\n", diem);
dat_gia_tri(&diem, 9);
printf("Sau: %d\n", diem);
dat_gia_tri(NULL, 9);
printf("Sau loi goi NULL, diem van la: %d\n", diem);
return 0;
}
Kết quả.
Truoc: 0
Sau: 9
Sau loi goi NULL, diem van la: 9
Phân tích chi tiết. Lần gọi thứ nhất đưa số nhà của diem. Hàm giữ bản sao của số nhà trong o, rồi *o = 9 ghi vào nhà gốc. Lần gọi NULL đi vào nhánh return ngay, không có *o, nên chương trình không chết và diem không bị đụng. Bỏ nhánh NULL rồi vẫn gọi dat_gia_tri(NULL, 9) là bài tự làm để quan sát chương trình bị dừng. Đừng coi việc “máy không chết” là bằng chứng an toàn: đó là may.
Ví dụ 3.9 — const chặn ghi qua con trỏ
Phân tích. Một điểm đã chốt không được sửa qua con trỏ chỉ đọc. Chương trình dưới đây biên dịch được. Bản gỡ chú thích ở dòng ghi sẽ bị trình biên dịch từ chối. Lưu đồ mô tả việc đọc hợp lệ.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int main(void)
{
int diem = 8;
const int *p = &diem;
printf("Doc qua con tro chi doc: %d\n", *p);
diem = 10;
printf("Sua bang ten bien van duoc: %d\n", *p);
/* *p = 11; lenh nay bi trinh bien dich tu choi */
return 0;
}
Kết quả.
Doc qua con tro chi doc: 8
Sua bang ten bien van duoc: 10
Phân tích chi tiết. const trên int * khóa cửa ghi qua con trỏ p, không khóa ngôi nhà. Tên diem vẫn ghi được, và lần đọc sau qua p thấy 10. Nếu cần khóa cả ngôi nhà, hãy khai báo const int diem = 8; thì diem = 10 cũng bị từ chối. Hai lớp const trả lời hai câu hỏi khác nhau: “con trỏ này có được ghi không?” và “biến này có được ghi không?”.
9. Bài tập có gợi ý
Bài 3.G1 — Đọc biểu thức trên giấy
a = 8, b = 3, c = 5, kiểu int. Tính: a + b * c, a % b, (a + b) % c, a / b, b > c, a != b && c > 0.
Gợi ý. Nhân chia trước cộng trừ. && sau quan hệ. Viết từng bước rút gọn, rồi mới viết chương trình in đối chứng.
Bài 3.G2 — Ba số thực
x = 8.8, y = 3.5, z = 5.2. Tính 5 * y + 6 * (x - z), (z / y) + x, 2.5 * x / z - (y + 6).
Gợi ý. Dùng double. In %.10f để thấy phần thập phân, đừng kết luận sai chỉ vì làm tròn hai chữ số.
Bài 3.G3 — Đổi giây sang giờ, phút, giây
Nhập số giây từ 0 đến 86399.
Gợi ý. gio = t / 3600, phut = (t % 3600) / 60, giay = t % 60. Từ chối ngoài đoạn. Đây là chia nguyên có chủ đích.
Bài 3.G4 — Hình cầu
Nhập bán kính không âm. Diện tích 4 * pi * r * r, thể tích (4.0 / 3.0) * pi * r * r * r.
Gợi ý. Viết 4.0 / 3.0, không viết 4 / 3. Nếu r là double, đừng vô tình chia nguyên hệ số.
Bài 3.G5 — Sai đặc tả scanf
Viết một chương trình cố ý dùng scanf("%f", &x) với x kiểu double. Quan sát cảnh báo -Wall. Sửa thành %lf.
Gợi ý. Kết quả “tình cờ đúng” trên một máy không xóa lỗi. Cảnh báo mới là dữ liệu cần chép vào vở.
Bài 3.G6 — Bật tắt bit
Một biến unsigned biểu diễn quyền: bit 0 đọc, bit 1 ghi, bit 2 xóa. Nhập một số là mặt nạ hiện tại và một số quyền cần bật thêm. In kết quả dạng thập phân và hexa.
Gợi ý. Chỉ dùng |. Đừng dùng + để “cộng quyền”: nếu bit đã bật, phép cộng làm lan sang bit bên cạnh.
Bài 3.G7 — Vẽ nhà trước khi chạy
Khai báo int x = 4; int y = 7; int *p = &x; int *q = &y;. Vẽ hai ngôi nhà và hai con trỏ. Viết kết quả của *p, *q sau từng dòng: *p = *q;, rồi p = q;, rồi *p = 1;.
Gợi ý. Dòng *p = *q chép nội dung, không chép địa chỉ. Dòng p = q làm hai con trỏ chỉ cùng một nhà. Dòng cuối sửa nhà đó, nên cả x lẫn y không cùng đổi: chỉ nhà mà cả hai đang chỉ mới đổi. Hãy chỉ ra nhà nào.
Bài 3.G8 — scanf tự viết bằng lời
Giải thích bằng một hình ô nhớ vì sao scanf("%d", n) sai và scanf("%d", &n) đúng khi n là int.
Gợi ý. Hàm nhận bản sao. Bản sao của giá trị không phải chỗ để ghi. Bản sao của địa chỉ vẫn chỉ đúng nhà.
Bài 3.G9 — Phát hiện khai báo sai
Chỉ ra lỗi của từng dòng, không cần chạy: int *p = 10;, int *q; *q = 10;, int *a, b;, printf("%d", p); khi p là int *.
Gợi ý. Dòng 1 gán địa chỉ bằng số 10. Dòng 2 đi theo địa chỉ rác. Dòng 3 chỉ a là con trỏ. Dòng 4 sai đặc tả in.
Bài 3.G10 — Hoán đổi qua địa chỉ, trên giấy
Viết thân hàm void hoan_doi(int *a, int *b) và bảng giá trị với x = 1, y = 2 trước và sau hoan_doi(&x, &y).
Gợi ý. Cần biến tạm int t = *a. Đừng viết int *t = a rồi chỉ đổi các con trỏ cục bộ: nơi gọi không thấy.
10. Bài tập tự làm
Cơ bản
- Nhập hai số nguyên, in tổng, hiệu, tích, thương nguyên và phần dư. Chặn chia cho 0.
- Nhập bán kính hình tròn, in chu vi và diện tích với 4 chữ số thập phân.
- Nhập một ký tự, in ký tự đó, mã của nó dưới dạng số nguyên, và ký tự đứng liền sau trong bảng mã.
- In kích thước mọi kiểu cơ bản trên máy bạn, kèm một câu kết luận máy bạn thuộc nhóm
long4 byte hay 8 byte.
Vững
- Nhập ngày, tháng, năm ở dạng ba số nguyên và in lại theo
dd/mm/yyyyvới số 0 đứng trước nếu cần (%02d). Chưa cần kiểm tra lịch đúng (Bài 4 sẽ làm). Vẫn phải kiểm trascanfđọc đủ 3 số. - Nhập một số thực là số tiền, in số tiền kèm
VNDvới 0 chữ số thập phân và với 2 chữ số. Giải thích sự khác nhau giữa làm tròn khi in và giá trị trong biến. - Tính giá trị biểu thức
2 * ((i / 4) + (6 * (j - 3)) % (i + j - 4))vớii,jnhập vào. Nếu mẫu của%bằng 0, từ chối. Làm trên giấy một ca trước khi chạy. - Viết chương trình chứng minh
010khác10. In cả hai bằng%d.
Nâng cao
- Nhập
unsigned intvà in biểu diễn nhị phân 32 bit của nó bằng cách dịch bit, không dùng thư viện chuỗi. Thứ tự in từ bit cao đến bit thấp. - Cộng 0.1f mười lần, một trăm lần, một nghìn lần. Lập bảng sai số so với giá trị toán học. Đề xuất một cách cộng “tiền” bằng số nguyên (đơn vị đồng hoặc xu) để hết sai số biểu diễn.
- Đọc
long longlà số mili-giây từ mốc nào đó, đổi sang ngày, giờ, phút, giây, mili-giây còn lại. Dùng kiểu đủ rộng cho các phép chia. Giải thích vì saointkhông an toàn nếu đầu vào có thể tới hàng tỷ.
Siêu khó, có ứng dụng
- Bộ giải mã gói tin 16 bit. Một khung cảm biến: bit 15..12 là loại cảm biến (0..15), bit 11..1 là giá trị đo (0..2047), bit 0 là cờ lỗi. Nhập khung dạng số nguyên không dấu. Tách ba trường bằng dịch và mặt nạ. In loại, giá trị, và “lỗi” hoặc “tốt”. Lập 5 ca, trong đó có ca cờ lỗi bật nhưng giá trị đo khác 0. Viết rõ các hằng mặt nạ. Không dùng chuỗi nhị phân làm đầu vào.
- Máy tính biểu thức có kiểm soát tràn. Nhập hai
long longvà một phép+ - *. Tính kết quả nếu vừalong long; nếu không, inTRANvà trả mã thoát 1. Với phép nhân, đừng nhân trực tiếp rồi “nhìn kết quả” vì tràn có dấu là hành vi không xác định. Hãy kiểm tra trước bằng phép chia hoặc bằng__int128nếu trình biên dịch của bạn hỗ trợ, và ghi rõ giả định đó trong chú thích. - Đặc tả làm tròn điểm học phần. Điểm thành phần là số thực 0..10. Điểm tổng kết =
0.1 * chuyen_can + 0.3 * giua_ky + 0.6 * cuoi_ky. In với hai chữ số. Viết thêm hàm so sánh “bằng nhau trong sai số 0.0005” để xác định một điểm có đang nằm sát ngưỡng làm tròn hay không (ví dụ 4.995). Nêu ca mà in hai chữ số ra5.00nhưng biến vẫn nhỏ hơn 5. Bài này là ứng dụng thật của việc tách giá trị và cách hiển thị.
Con trỏ — từ dễ đến khó
- Khai báo
int n = 42; int *p = &n;. Inn,*p,&n,p. Chỉ ra hai dòng nào phải trùng. - Viết chương trình hỏi người dùng một số nguyên bằng
scanf("%d", &n), rồi in gấp đôi số đó. Trong chú thích, ghi một câu: dấu&đưa địa chỉ nào choscanf. - Viết
void nhan_doi(int *o)tăng gấp đôi ô được trỏ. Gọi với&xkhix = 6. In12. Thêm lời gọi vớiNULLvà chứng minh chương trình không ghi gì. - Có ba biến
int a, b, c. Con trỏplần lượt chỉa, rồib, rồic. Mỗi lần chỉ, ghi vào ô đó đúng số thứ tự 1, 2, 3. Ina b cphải ra1 2 3. Không được gána = 1bằng têna. - Viết hàm
int doc(const int *o)trả về*onếuokhácNULL, trả về-1nếuolàNULL. Giải thích vì sao trong hàm này không viết được*o = 0. - Nhập hai số. Dùng một con trỏ
int *lonđể chỉ vào biến lớn hơn (nếu bằng nhau thì chỉ vào biến thứ nhất). In giá trị qua*lon, rồi tăng*lonthêm 1 và in lại cả hai biến để thấy chỉ một biến đổi. - Tìm và sửa:
int *p; scanf("%d", p);. Viết lại đúng. Giải thích lỗi cũ bằng hình ô nhớ, không bằng câu “thiếu dấu sao”. - Viết
void sap_hai_o(int *x, int *y)để sau lời gọi, ôxchỉ không lớn hơn ôy. Dùng hoán đổi ba bước nếu cần. Kiểm thử(9, 2),(2, 9),(4, 4), và(NULL, &a).
Siêu khó
- Ba con trỏ, một ngôi nhà.
int x = 5; int *p = &x; int *q = p; int *r = &x;. Tăng*qlên 1, rồi gán*r = *p + *q. Tínhxtrên giấy rồi chạy đối chiếu. Viết một đoạn 8 dòng giải thích vì sao ba con trỏ không tạo ra ba bản sao củax. - Hằng đúng lớp. Viết bốn chương trình ngắn, mỗi chương trình một dòng bị cấm, chứng minh trình biên dịch từ chối: ghi qua
const int *, đổiint *const, ghi vàoconst int, và gánint *từconst int *không có ép kiểu. Chép thông báo lỗi vào bài nộp. - Mô phỏng thanh ghi thiết bị. Một
unsigned int trang_thailà ô nhớ của cảm biến. Viếtvoid bat_bit(unsigned int *reg, unsigned int mask)vàvoid tat_bit(...),int dang_bat(const unsigned int *reg, unsigned int mask). Cấm hàm nào nhậnNULL. Kiểm thử bật bit 0 và bit 3, tắt bit 0, đọc lại. Đây là đúng kiểu API điều khiển phần cứng: hàm không nhận giá trị thanh ghi, hàm nhận địa chỉ thanh ghi.
Dự án thật
- Sổ điểm thô trong một lần chạy. Chương trình giữ năm biến
doublelà năm cột điểm của một sinh viên. Viết hàmint nhap_diem(double *o)dùngscanfvào đúng địa chỉo, từ chối điểm ngoài 0..10 bằng cách trả 0, thành công trả 1.mainchỉ gọinhap_diem(&chuyen_can)và các cột còn lại, không viếtscanftrongmain. In điểm trung bình. Nếu một cột nhập sai quá 3 lần, dừng với mã thoát 1. Bài này ép bạn dùng con trỏ như công cụ thiết kế, không như bài tập cú pháp. - Nhật ký sửa giá. Một cửa hàng có biến
long long gia. Viếtvoid dat_gia(long long *gia, long long moi, long long *gia_cu): lưu giá hiện tại vào*gia_curồi ghi giá mới, từ chối giá âm và con trỏNULLbằng cách trảintthành công hoặc thất bại.mainchạy 5 lần đổi giá và in từng cặp giá cũ, giá mới. Kiểu tiền là đồng nguyên, không dùngfloat.
11. Câu hỏi tự kiểm
char,'A',"A"khác nhau thế nào?- Vì sao
7 / 2và7.0 / 2khác nhau? scanf("%f", &x)vớixlàdoublesai ở đâu?&&và&khác nhau chỗ nào?- Tràn
unsignedvà trànintcó dấu, cái nào được chuẩn định nghĩa? sizeof(int)trên máy bạn là bao nhiêu, và chuẩn đảm bảo tối thiểu bao nhiêu?&xvà*pmỗi toán tử đi theo chiều nào?int *p = NULL; *p = 1;sai ở đâu?p = &bkhác*p = bthế nào?- Vì sao
const int *pvẫn thấy giá trị mới khi sửa biến bằng tên của biến?
12. Tóm tắt và cầu nối
C nhìn chương trình như token, kiểu, tên, hằng, biểu thức và câu lệnh. Kiểu quyết định miền giá trị và phép toán. Biểu thức tuân độ ưu tiên; chia nguyên và ép kiểu là hai chỗ sai nhiều nhất. printf/scanf đòi đúng đặc tả và, với scanf, đúng địa chỉ cùng giá trị trả về.
Con trỏ là biến giữ số nhà của một ô nhớ. & hỏi số nhà. * mở ô tại số nhà đó. NULL là cố ý không có nhà. Con trỏ chưa khởi tạo là nhà rác. scanf cần & vì hàm phải ghi vào ô của người gọi. Nắm hình hai ngôi nhà trước khi học mảng: tên mảng sau này chính là địa chỉ phần tử đầu.
Bài 4 dùng các biểu thức quan hệ và logic vừa học để lái chương trình theo những nhánh loại trừ nhau.
Bộ 25 bài tập
- Tính trên giấy
8 + 3 * 5và(8 + 3) * 5. Viết chương trình in cả hai. Gợi ý: Nhân trước cộng. In cả hai kết quả trên hai dòng. - Chứng minh
010khác10bằng một chương trình in cả hai theo%d. Gợi ý: Số bắt đầu bằng 0 là bát phân. - Nhập một ký tự, in ký tự, mã số, và ký tự liền sau. Gợi ý: printf với %c và %d. Ký tự sau là c + 1.
- In
sizeofcủachar,int,long,doubletrên máy bạn. Gợi ý: Dùng %zu cho sizeof. - Nhập hai số nguyên, in tổng, hiệu, tích. Chặn chưa cần chia. Gợi ý: Ba printf. Chưa cần phép chia.
- Khai báo
int n = 42; int *p = &n;. Inn,*p, và hai địa chỉ phải trùng. Gợi ý: %p in địa chỉ. Ép (void *) khi in con trỏ. - Vẽ hai ngôi nhà cho
a = 3,b = 8,p = &a. Điền*psau*p = *p + 1. Gợi ý: Dòng này sửa a, chưa đụng b. - Chỉ ra lỗi của dòng
int *q; *q = 10;bằng một câu về số nhà. Gợi ý: q chưa giữ số nhà nào. Cấm *q. - Nhập hai số nguyên, in thương nguyên và phần dư. Chặn chia cho 0. Gợi ý: Nếu b == 0 thì return 1 trước khi chia hoặc lấy dư.
- Đổi số giây từ 0 đến 86399 sang giờ, phút, giây. Gợi ý: gio = t/3600, phut = (t%3600)/60, giay = t%60.
- Nhập bán kính không âm, in diện tích và thể tích hình cầu. Hệ số viết
4.0/3.0. Gợi ý: Dùng double và 4.0/3.0. r âm thì dừng. - Trung bình hai điểm nguyên phải ra số thực. Ép kiểu đúng chỗ. Ca 7 và 8 ra 7.50. Gợi ý: (double)(a + b) / 2.0. Đừng chia nguyên trước.
- Viết chương trình cộng
0.1fmười lần và in kết quả so với 1. Gợi ý: In %.10f. Đừng kết luận bằng ==. scanfmộtdoublebằng đúng đặc tả%lf. Cố ý dùng%fmột lần để xem cảnh báo, rồi sửa. Gợi ý: double đi với %lf trong scanf.- Hàm
void nhan_doi(int *o)nhân đôi ô được trỏ. Có nhánhNULL. Gợi ý: Nếu o khác NULL thì *o = *o * 2. - Ba biến
a, b, c. Con trỏ lần lượt ghi 1, 2, 3 vào từng ô. Cấm gán bằng tên biến. Gợi ý: p = &a rồi *p = 1, rồi làm tương tự với b và c. - In 32 bit của một
unsigned inttừ bit cao xuống bit thấp. Gợi ý: Mặt nạ 1u << k, dịch từ bit 31 xuống bit 0. - Bật bit 0 và bit 3 của một thanh ghi, tắt bit 0, in hexa. Gợi ý: Bật bằng |, tắt bằng & ~mask.
const int *pđọc được điểm, dòng*p = 1phải bị trình biên dịch từ chối. Chép lỗi. Gợi ý: const đứng trước int * thì cấm ghi qua con trỏ.- Hai
long longvà một phép+hoặc*. Nếu kết quả không vừa kiểu, inTRANmà không gây tràn có dấu. Gợi ý: Kiểm tra trước khi cộng hoặc nhân. Không nhìn kết quả sau khi tràn. - Tách một khung 16 bit: bit 15..12 loại cảm biến, bit 11..1 giá trị đo, bit 0 cờ lỗi. Năm ca. Gợi ý: Tách bằng dịch và &. Đừng đọc nhầm bit 0 thành giá trị đo.
- Sổ điểm một lần chạy. Năm biến
double. Hàmnhap_diem(double *o)từ chối điểm ngoài 0..10.mainkhông gọiscanftrực tiếp. Quá ba lần sai thì thoát mã 1. Gợi ý: Mỗi cột điểm một lời gọi nhap_diem(&bien). - Đổi giá.
dat_gia(long long *gia, long long moi, long long *gia_cu)lưu giá cũ rồi ghi giá mới, từ chối âm vàNULL. Năm lần đổi, in từng cặp. Gợi ý: Lưu *gia vào *gia_cu rồi mới gán giá mới. - Tiền không dùng số thực. Cộng tiền bằng đơn vị đồng
long long. In số tiền với hậu tốVND. Một đoạn giải thích vì sao không dùngfloat. Gợi ý: Nhân số lượng với đơn giá bằng long long. - Bảng sai số. Cộng 0.1 một trăm lần bằng
floatvà bằngdouble. Bảng sai số so với 10. Kết luận một câu cho việc cộng tiền. Gợi ý: Sai số = giá trị máy trừ 10. float lệch nhiều hơn double.