Nhập xuất với printf và scanf
Một chương trình chỉ thực sự hữu ích khi nó biết nói chuyện với người dùng: nhận dữ liệu vào và in kết quả ra. Bài này tập trung vào hai hàm quen thuộc nhất của ngôn ngữ C cho việc đó, là printf để xuất và scanf để nhập. Cả hai đều nằm trong thư viện <stdio.h>, nên mọi chương trình ở đây đều bắt đầu bằng dòng khai báo thư viện đó.
Hãy nghĩ về một máy tính bỏ túi: nó vô dụng nếu chỉ biết tính mà không có phím bấm và màn hình. Nhập xuất là hai đầu dây nối chương trình với thế giới bên ngoài, và ở C thì hai đầu dây ấy chạy qua khái niệm dòng dữ liệu (stream): bàn phím là dòng vào chuẩn, màn hình là dòng ra chuẩn. Điểm khiến người mới hay vấp là dòng dữ liệu không tức thời như ta tưởng. Gõ một số rồi bấm Enter, cả con số lẫn ký tự xuống dòng đều xếp vào một hàng chờ gọi là đệm (buffer), hàm nhập chỉ lấy đúng phần nó cần, phần thừa nằm lại chờ lượt sau. Rất nhiều lỗi kiểu "chương trình bỏ qua lệnh nhập của tôi" bắt nguồn từ đúng chỗ này.
Xuất dữ liệu với printf
Hàm printf viết tắt của print formatted, nghĩa là in có định dạng. Nhiệm vụ của nó là in một dãy ký tự ra màn hình. Dạng đơn giản nhất là in một chuỗi cố định.
#include <stdio.h>
int main(void) {
printf("Xin chao lop ITP101\n"); // print a fixed string
return 0;
}
Dấu \n ở cuối chuỗi không phải là hai ký tự gạch chéo và chữ n, mà là một ký tự đặc biệt: ký tự xuống dòng (newline). Nếu thiếu nó, con trỏ in vẫn nằm ở cuối dòng hiện tại và lần in tiếp theo sẽ nối liền vào.
Định dạng và đối số
Sức mạnh thật sự của printf là in giá trị của biến. Khi đó chuỗi đầu tiên truyền cho printf được gọi là chuỗi định dạng (format string). Trong chuỗi này, mỗi chỗ cần chèn một giá trị được đánh dấu bằng một đặc tả chuyển đổi bắt đầu bằng dấu %. Các giá trị thật sự được liệt kê sau chuỗi định dạng, ngăn cách bằng dấu phẩy.
#include <stdio.h>
int main(void) {
int tuoi = 19;
float diem = 8.5f;
char hang = 'A';
printf("Tuoi %d, diem %f, hang %c\n", tuoi, diem, hang);
return 0;
}
Khi chạy, chương trình in ra Tuoi 19, diem 8.500000, hang A. Hãy để ý cách printf ghép từng đặc tả với từng đối số theo đúng thứ tự từ trái sang phải: %d nhận tuoi, %f nhận diem, %c nhận hang.
Số lượng đặc tả % phải khớp với số đối số, và kiểu của đặc tả phải khớp với kiểu của đối số. In một số thực float bằng %d, hay in một số nguyên bằng %f, đều cho kết quả sai lệch và khó đoán. Trình biên dịch thường chỉ cảnh báo chứ không báo lỗi, nên đây là loại lỗi rất dễ lọt.
Các đặc tả thông dụng và ký tự điều khiển
Bảng dưới liệt kê các đặc tả hay dùng nhất ở giai đoạn nhập môn. Còn các ký tự bắt đầu bằng dấu gạch chéo ngược được gọi là chuỗi thoát (escape sequence): chúng biểu diễn những ký tự không gõ trực tiếp được, ví dụ xuống dòng hay nhảy cột. Bảng thứ hai gộp thêm %%, vốn không phải chuỗi thoát mà là cách printf in ra một dấu phần trăm thật thay vì hiểu nó là mở đầu một đặc tả.
| Đặc tả | Dùng cho kiểu | In ra |
|---|---|---|
%d | int | số nguyên có dấu, hệ thập phân |
%f | float, double | số thực, mặc định 6 chữ số sau dấu chấm |
%c | char | một ký tự đơn |
%s | chuỗi (char[]) | chuỗi ký tự cho tới ký tự kết thúc |
%x | int | số nguyên ở hệ thập lục phân (hệ 16) |
| Ký hiệu | Ý nghĩa |
|---|---|
\n | xuống dòng mới |
\t | nhảy một bước canh cột (tab) |
\\ | in một dấu gạch chéo ngược |
%% | in một dấu phần trăm |
Ta có thể tinh chỉnh cách in. Với số thực, %.2f yêu cầu in đúng hai chữ số sau dấu chấm; %8.2f dành tối thiểu tám cột cho cả số và căn phải. Với số nguyên, %5d dành năm cột. Đây gọi là độ rộng trường (field width) và độ chính xác (precision).
#include <stdio.h>
int main(void) {
printf("Mat hang\tGia\n"); // header, tab separates columns
printf("But bi\t%8.2f\n", 5000.0); // width 8, 2 decimals
printf("Vo\t%8.2f\n", 12000.0);
int ma = 255;
printf("Ma hex cua %d la %x\n", ma, ma); // 255 -> ff
return 0;
}
Dòng cuối in ra Ma hex cua 255 la ff, vì 255 trong hệ 16 là ff. Cùng một giá trị 255 được in hai cách: hệ thập phân qua %d và hệ 16 qua %x.
Đọc kỹ cấu trúc của một đặc tả
Một đặc tả đầy đủ có dạng %, rồi cờ, rồi độ rộng, rồi dấu chấm và độ chính xác, rồi chữ cái kiểu. Ta ít khi dùng hết, nhưng hiểu từng phần thì việc canh bảng trở nên dễ dàng.
| Thành phần | Ví dụ | Tác dụng |
|---|---|---|
| cờ căn trái | %-10s | dành 10 cột, chữ nằm sát bên trái |
| độ rộng | %5d | dành tối thiểu 5 cột, mặc định căn phải |
| độ chính xác với số thực | %.3f | in đúng 3 chữ số sau dấu chấm, có làm tròn |
| độ chính xác với chuỗi | %.4s | chỉ in 4 ký tự đầu của chuỗi |
| gộp cả hai | %8.2f | tổng cộng 8 cột, trong đó 2 chữ số thập phân |
| cờ số 0 | %05d | dành 5 cột, chỗ trống điền số 0 thay vì dấu cách |
Độ rộng là số cột tối thiểu, không phải tối đa. Nếu giá trị dài hơn, printf vẫn in đủ chứ không cắt bớt, nên bảng sẽ lệch cột. Muốn bảng luôn thẳng hàng thì phải chọn độ rộng đủ lớn cho giá trị dài nhất có thể xuất hiện.
#include <stdio.h>
int main(void) {
printf("%-12s%8s\n", "Mat hang", "Thanh tien"); // header, left + right
printf("%-12s%8.2f\n", "But bi", 15000.0);
printf("%-12s%8.2f\n", "Vo o ly", 144000.0);
printf("Ma don hang: %05d\n", 42); // 00042
return 0;
}
Cột tên hàng dùng %-12s để căn trái cho dễ đọc, cột tiền dùng độ rộng cố định và căn phải nên các chữ số thẳng nhau. Dòng cuối in 00042: cờ số 0 rất tiện khi cần mã có độ dài cố định như mã sinh viên hay mã đơn hàng.
Viết printf("%.2f\n", 3.14159) in ra 3.14, nhưng biến vẫn giữ nguyên giá trị đầy đủ. Đặc tả chỉ quyết định cách hiển thị. Cần giá trị đã làm tròn để tính tiếp thì phải tự làm tròn trong biểu thức, đừng trông cậy vào printf.
Nhập dữ liệu với scanf
Hàm scanf viết tắt của scan formatted, làm điều ngược với printf: nó đọc dữ liệu người dùng gõ từ bàn phím và cất vào biến. Cấu trúc gần giống printf: một chuỗi định dạng cho biết kiểu dữ liệu cần đọc, rồi tới danh sách nơi cất giá trị.
#include <stdio.h>
int main(void) {
int n;
printf("Nhap mot so nguyen: ");
scanf("%d", &n); // read into the address of n
printf("Ban vua nhap %d\n", n);
return 0;
}
Điểm khác biệt then chốt nằm ở dấu & đặt trước tên biến. Toán tử & đọc là lấy địa chỉ (address-of): nó cho biết biến n nằm ở đâu trong bộ nhớ. Hàm scanf cần biết địa chỉ để có thể đặt giá trị vừa đọc vào đúng ô nhớ đó. Nếu chỉ viết scanf("%d", n) thì ta đang đưa cho scanf giá trị hiện tại của n chứ không phải chỗ cất, và chương trình sẽ chạy sai hoặc dừng đột ngột.
Quên dấu & trong scanf là lỗi kinh điển của người mới học C. Trình biên dịch nhiều khi không báo lỗi mà chỉ cảnh báo, nên hãy tạo thói quen tự hỏi: mình có & trước mỗi biến chưa? Có một ngoại lệ: khi đọc vào một chuỗi (mảng char) bằng %s thì không cần &, vì tên mảng đã là địa chỉ. Phần này sẽ rõ hơn ở bài về mảng và con trỏ.
Có thể nhập nhiều giá trị trong một lời gọi. Chuỗi định dạng "%d %d" bảo scanf đọc hai số nguyên, người dùng gõ cách nhau bằng dấu cách hoặc xuống dòng.
#include <stdio.h>
int main(void) {
int a, b;
printf("Nhap hai so nguyen: ");
scanf("%d %d", &a, &b); // read two integers
printf("Tong = %d\n", a + b); // a + b is an expression
return 0;
}
Biểu thức a + b được tính trước, ra một giá trị, rồi giá trị đó mới được truyền cho printf để in. Đây là cầu nối tự nhiên sang bài về toán tử và biểu thức.
scanf trả về một con số, và ta nên đọc con số đó
Hàm scanf trả về số mục nó đã đọc và gán được. Đọc một số nguyên thành công thì trả về 1. Nếu người dùng gõ chữ abc thay vì một con số, scanf không gán được gì và trả về 0, còn biến n giữ nguyên giá trị rác cũ, và chương trình cứ thế tính toán trên rác.
#include <stdio.h>
int main(void) {
int n;
printf("Nhap mot so nguyen: ");
if (scanf("%d", &n) != 1) { // 1 means one item was read
printf("Du lieu khong hop le\n");
return 1;
}
printf("Binh phuong = %d\n", n * n);
return 0;
}
Với hai giá trị thì con số cần so là 2, ví dụ if (scanf("%d %d", &a, &b) != 2). Thói quen này tách bạch hai tình huống rất khác nhau: người dùng gõ đúng, và người dùng gõ bậy.
scanf xử lý khoảng trắng thế nào
Các đặc tả số như %d, %f, %lf và cả %s đều tự bỏ qua mọi khoảng trắng đứng trước, kể cả dấu cách, dấu tab và ký tự xuống dòng. Nhờ vậy scanf("%d %d", &a, &b) chạy tốt dù người dùng gõ hai số trên một dòng hay gõ số thứ nhất rồi Enter mới gõ số thứ hai.
Riêng %c thì không bỏ qua khoảng trắng: nó lấy đúng ký tự tiếp theo trong đệm, mà ký tự đó rất có thể là dấu xuống dòng còn sót lại từ lần nhập trước.
#include <stdio.h>
int main(void) {
int tuoi;
char loai;
printf("Nhap tuoi: ");
scanf("%d", &tuoi); // reads the number, leaves '\n' in the buffer
printf("Nhap loai ve (A/B): ");
scanf(" %c", &loai); // the space before %c skips the leftover '\n'
printf("Tuoi %d, ve loai %c\n", tuoi, loai);
return 0;
}
Chú ý dấu cách trong chuỗi định dạng " %c". Dấu cách đó không có nghĩa là "chờ một dấu cách", mà là "bỏ qua mọi khoảng trắng đang chờ trong đệm". Thiếu nó, biến loai nhận đúng ký tự xuống dòng và chương trình chạy vụt qua mà không chờ ai gõ gì cả.
Đặc tả %s cũng có bẫy riêng: nó đọc tới khoảng trắng đầu tiên rồi dừng. Nhập "Nguyen Van A" bằng %s thì bạn chỉ nhận được "Nguyen", phần còn lại kẹt trong đệm.
Đệm bàn phím và cách xoá đệm
Gõ 25 rồi Enter, đệm chứa ba ký tự: 2, 5 và ký tự xuống dòng. Lệnh scanf("%d", &tuoi) lấy ra 25, ký tự xuống dòng nằm lại. Lượt đọc kế tiếp bằng %c hay bằng một hàm đọc cả dòng sẽ vớ ngay ký tự thừa ấy. Cách thứ nhất để chữa là thêm dấu cách trước %c như trên. Cách thứ hai là chủ động dọn sạch đệm cho tới hết dòng.
#include <stdio.h>
void xoa_dem(void) {
int c;
while ((c = getchar()) != '\n' && c != EOF) {
// discard characters up to and including the newline
}
}
int main(void) {
int tuoi;
printf("Nhap tuoi: ");
if (scanf("%d", &tuoi) != 1) return 1;
xoa_dem(); // clear the rest of the line
printf("Da doc tuoi = %d, dem da sach\n", tuoi);
return 0;
}
Hàm getchar lấy ra từng ký tự một, vòng lặp bỏ đi cho tới khi gặp ký tự xuống dòng hoặc hết dữ liệu. Biến c phải là int chứ không phải char, vì EOF là một giá trị âm không nằm trong tầm của char trên nhiều hệ thống.
Trên mạng đầy lời khuyên gọi fflush(stdin) để xoá đệm bàn phím. Chuẩn C không định nghĩa hành vi của fflush trên dòng vào, nghĩa là nó có thể chạy đúng trên máy bạn, chạy sai trên máy thầy, và im lặng không làm gì trên máy chấm bài. Hãy dùng vòng lặp getchar như trên: dài hơn vài dòng nhưng đúng ở mọi nơi.
fgets an toàn hơn gets
Để đọc cả một dòng văn bản có dấu cách, C từng có hàm gets. Hàm này đọc tới khi hết dòng và không hề biết mảng đích dài bao nhiêu. Người dùng gõ 200 ký tự vào một mảng 20 ô thì 180 ký tự thừa ghi đè lên bộ nhớ bên cạnh. Đó là lỗi tràn bộ đệm (buffer overflow), nguồn gốc của vô số lỗ hổng bảo mật, tới mức gets đã bị loại bỏ khỏi chuẩn C. Đừng bao giờ dùng nó. Hàm thay thế là fgets, nhận thêm một đối số là kích thước tối đa nên không bao giờ ghi tràn.
#include <stdio.h>
#include <string.h> // strcspn, strlen
int main(void) {
char ten[50];
printf("Nhap ho ten: ");
if (fgets(ten, sizeof(ten), stdin) == NULL) return 1;
ten[strcspn(ten, "\n")] = '\0'; // cut the trailing newline, if any
printf("Xin chao [%s], ten dai %zu ky tu\n", ten, strlen(ten));
return 0;
}
Ba đối số của fgets lần lượt là mảng đích, số ký tự tối đa được ghi (kể cả ký tự kết thúc chuỗi) và dòng dữ liệu để đọc, ở đây là bàn phím stdin. Nó trả về NULL khi không đọc được gì.
Một chi tiết dễ quên: fgets giữ lại cả ký tự xuống dòng. Chuỗi "Hao" đọc bằng fgets thực ra là "Hao" cộng ký tự xuống dòng, in ra trông vẫn bình thường nhưng so sánh chuỗi sẽ sai và độ dài lệch một. Dòng ten[strcspn(ten, "\n")] = '\0'; tìm vị trí ký tự xuống dòng rồi thay bằng ký tự kết thúc chuỗi.
Nếu ngay trước fgets bạn có một scanf("%d", ...), ký tự xuống dòng còn sót lại trong đệm sẽ khiến fgets đọc được một dòng rỗng và đi tiếp luôn, người dùng không kịp gõ gì. Muốn trộn hai hàm thì phải xoá đệm giữa chúng bằng vòng lặp getchar đã nêu ở trên. Cách an toàn hơn cho chương trình lớn là dùng fgets cho mọi lượt nhập, rồi tự chuyển chuỗi sang số bằng sscanf.
Một ví dụ tính toán đơn giản
Ghép cả nhập lẫn xuất, ta viết một chương trình nhỏ tính chu vi và diện tích hình tròn từ bán kính người dùng nhập vào. Bán kính để kiểu double cho khỏi mất phần thập phân, và kết quả in với hai chữ số sau dấu chấm.
#include <stdio.h>
int main(void) {
double r;
printf("Nhap ban kinh: ");
scanf("%lf", &r); // %lf reads a double
double chuvi = 2 * 3.14159 * r; // perimeter
double dientich = 3.14159 * r * r; // area
printf("Chu vi = %.2f\n", chuvi);
printf("Dien tich = %.2f\n", dientich);
return 0;
}
Lưu ý nhỏ: khi đọc một số double bằng scanf, đặc tả là %lf chứ không phải %f. Còn khi in ra bằng printf thì %f đã đủ cho cả float lẫn double. Sự lệch nhỏ này giữa nhập và xuất là một điểm hay nhầm, nên đáng ghi nhớ ngay từ đầu.
Bài tập thực hành
Bài tập 1: in thông tin cá nhân
Viết chương trình in ra màn hình đúng ba dòng: họ tên, lớp và trường của bạn, mỗi thông tin trên một dòng (dùng \n). Sau đó in lại ba thông tin trên một dòng nhưng ngăn cách bằng dấu \t.
Lời giải chi tiết
Phần đầu chỉ cần ba lời gọi printf, mỗi lời kết thúc bằng \n để xuống dòng. Phần sau gộp cả ba vào một chuỗi duy nhất, dùng \t giữa các phần để chúng nằm trên cùng một dòng nhưng canh cột.
#include <stdio.h>
int main(void) {
printf("Nguyen Van A\n");
printf("ITP101\n");
printf("Dai hoc Kien truc Da Nang\n");
printf("Nguyen Van A\tITP101\tDai hoc Kien truc Da Nang\n");
return 0;
}
Ý chính: \n xuống dòng, \t canh cột; cùng dữ liệu nhưng cách trình bày khác nhau hoàn toàn nhờ hai ký tự điều khiển này.
Bài tập 2: đổi nhiệt độ
Viết chương trình nhập nhiệt độ theo độ C (kiểu double) rồi đổi sang độ F theo công thức F = C * 9 / 5 + 32. In kết quả với một chữ số thập phân.
Lời giải chi tiết
Vì C đã là double, phép C * 9 / 5 được tính theo kiểu thực nên không bị lỗi chia nguyên. Đọc số thực bằng %lf, in bằng %.1f.
#include <stdio.h>
int main(void) {
double c;
printf("Nhap nhiet do C: ");
scanf("%lf", &c);
double f = c * 9 / 5 + 32; // C is double, so this is real math
printf("Tuong duong %.1f do F\n", f);
return 0;
}
Ý chính: chỉ cần một toán hạng là số thực thì cả biểu thức chia tính theo kiểu thực, tránh được cái bẫy chia nguyên.
Bài tập 3: in một số ở hai hệ
Viết chương trình nhập một số nguyên n rồi in ra giá trị của n ở hệ thập phân (%d) và hệ thập lục phân (%x), kèm số dư và thương khi chia n cho 3.
Lời giải chi tiết
Cùng một biến n được in hai lần với hai đặc tả khác nhau. Thương lấy bằng phép chia nguyên n / 3, số dư lấy bằng n % 3.
#include <stdio.h>
int main(void) {
int n;
printf("Nhap mot so nguyen: ");
scanf("%d", &n);
printf("Thap phan: %d, hex: %x\n", n, n);
printf("Thuong khi chia 3: %d, du: %d\n", n / 3, n % 3);
return 0;
}
Ý chính: đặc tả quyết định cách hiển thị, còn / cho thương nguyên và % cho số dư của phép chia nguyên.
Bài tập 4: nhập tên đầy đủ rồi chào
Viết chương trình nhập họ tên đầy đủ (có dấu cách) và tuổi, sau đó in một dòng chào kèm tuổi. Phải dùng fgets cho họ tên, cắt bỏ ký tự xuống dòng, và xử lý đúng phần đệm giữa hai lượt nhập.
Lời giải chi tiết
Thứ tự nhập quyết định mức độ rắc rối. Nếu đọc số trước rồi mới đọc chuỗi, ta buộc phải xoá đệm ở giữa. Ở đây ta đọc chuỗi trước bằng fgets, cắt ký tự xuống dòng, rồi mới đọc số, nên không cần dọn dẹp gì. Đó cũng là mẹo nên nhớ khi làm bài tập.
#include <stdio.h>
#include <string.h>
int main(void) {
char ten[64];
int tuoi;
printf("Nhap ho ten: ");
if (fgets(ten, sizeof(ten), stdin) == NULL) return 1;
ten[strcspn(ten, "\n")] = '\0'; // remove trailing newline
printf("Nhap tuoi: ");
if (scanf("%d", &tuoi) != 1) { // check the return value
printf("Tuoi khong hop le\n");
return 1;
}
printf("Xin chao %s, ban %d tuoi\n", ten, tuoi);
return 0;
}
Ý chính: fgets đọc được cả dấu cách nhưng giữ lại ký tự xuống dòng, nên luôn cắt nó đi ngay sau khi đọc.
Câu hỏi tự kiểm
- 1Để đọc một biến kiểu double bằng scanf, đặc tả đúng là gì?
- 2Vì sao scanf cần dấu & trước tên một biến số như &n?
- 3Vì sao nên viết " %c" (có một dấu cách đứng trước) thay vì "%c" khi đọc một ký tự sau khi đã đọc số?
- 4Vì sao không bao giờ nên dùng hàm gets để đọc một dòng?
Tóm tắt
Về xuất, printf in theo chuỗi định dạng: mỗi % là một chỗ trống được điền bằng đối số tương ứng, đúng thứ tự và đúng kiểu. Độ rộng trường canh cột, độ chính xác quyết định số chữ số thập phân khi hiển thị nhưng không đổi giá trị biến. Sai kiểu giữa đặc tả và đối số thường chỉ bị cảnh báo chứ không bị chặn.
Về nhập, scanf cần địa chỉ nên phải có & trước tên biến, trừ khi đọc chuỗi bằng %s. Nó trả về số mục đọc được, bỏ qua con số đó nghĩa là chấp nhận tính toán trên dữ liệu rác. Các đặc tả số tự bỏ qua khoảng trắng đứng trước, riêng %c thì không nên phải viết " %c". Ký tự xuống dòng luôn nằm lại trong đệm sau mỗi scanf, dọn nó bằng vòng lặp getchar chứ không phải fflush(stdin). Để đọc cả dòng có dấu cách, dùng fgets kèm kích thước mảng, không bao giờ dùng gets, và nhớ cắt ký tự xuống dòng mà fgets giữ lại.
printf in có định dạng qua các đặc tả %d, %f, %c, %s, %x và các ký tự điều khiển như \n, \t. scanf đọc dữ liệu vào biến và luôn cần toán tử lấy địa chỉ & trước tên biến (trừ khi đọc chuỗi bằng %s). Nhớ rằng đọc double dùng %lf còn in thì %f là đủ.