Professional Documents
Culture Documents
Learning CPP
Learning CPP
Phần này giới thiệu về cấu trúc của một chương trình C++, mục đích là để giúp bạn
nhanh chóng viết được những chương trình đầu tiên, qua đó hiểu cách thức soạn
thảo, dịch và chạy chương trình. Các khái niệm sẽ được giải thích chi tiết hơn trong
các chương sau.
1
Phần I
GIỚI THIỆU C++
*Hai thuật ngữ này lẽ ra nên gọi là máy lập trình (computer) và máy tính (calculator), nhưng chúng
đều được gọi là “máy tính” trong tiếng Việt.
2
Lê Minh Hoàng
Chương 1
Lập trình và ngôn ngữ lập trình
dễ viết và dễ bảo trì cũng như nâng cấp. Những chương trình viết ra phải được
dịch thành mã máy thông qua một chương trình dịch (hay trình dịch). Trình dịch
phụ thuộc vào phần cứng, tức là trên mỗi kiến trúc máy tính, mỗi hệ điều hành, cần
có trình dịch riêng. Có hai kiểu trình dịch:
Thông dịch (interpreter): Dịch từng dòng lệnh và chạy luôn dòng lệnh đó. Quá
trình này giống như cách làm của một thông dịch viên. Điều đó có nghĩa là trình
thông dịch luôn phải hiện diện trong máy để chạy chương trình.
Biên dịch (compiler): Dịch toàn bộ chương trình ra mã máy trong một tiến trình
độc lập, mã máy này sau đó có thể thực hiện trực tiếp trên máy tính mà không
cần sự hiện diện của trình biên dịch nữa. Quá trình này giống như khi ta dịch
sách.
Vì nhiều lý do kỹ thuật, đặc biệt là việc thông dịch phải tích hợp cả tiến trình dịch
vào trong tiến trình thực thi, nên các chương trình chạy bằng thông dịch chậm hơn
rất nhiều so với sản phẩm tạo ra bằng cách biên dịch. Các phần mềm biên dịch còn
tích hợp những khả năng tự động tối ưu mã nên tốc độ thực thi của những mã biên
dịch hiện đại không bị thua kém quá nhiều so với chương trình viết trực tiếp bằng
ngôn ngữ máy hay hợp ngữ.
Chú ý rằng không có khái niệm ngôn ngữ biên dịch hay thông dịch. Thông dịch hay
biên dịch chỉ là cách thức chuyển ngôn ngữ lập trình cấp cao sang ngôn ngữ máy.
Một số ngôn ngữ lập trình có cả trình biên dịch và trình thông dịch, thậm chí có
ngôn ngữ lập trình không có trình dịch, chúng chỉ tồn tại trên thiết kế và chưa bao
giờ được sử dụng để viết phần mềm trong thực tế.
Ý tưởng về ngôn ngữ lập trình cấp cao bắt đầu từ cuối những năm 1940s và cho
tới ngày nay đã có hàng trăm ngôn ngữ lập trình. Ta điểm qua một vài sự kiện quan
trọng:
Năm 1954, ngôn ngữ FORTRAN được phát minh tại IBM, FORTRAN là ngôn ngữ
lập trình cấp cao đầu tiên được sử dụng rộng rãi, có trình biên dịch đi kèm.
Cuối những năm 1950, một hội đồng những nhà khoa học máy tính tại Mỹ và Châu
Âu thành lập đề án xây dựng ngôn ngữ ALGOL (ALGOrithmic Language), trong đó
đưa ra 3 đặc tả quan trọng của ngôn ngữ được dùng cho cả những thế hệ ngôn ngữ
sau này, đó là: Cấu trúc khối lồng nhau, phạm vi hiệu lực của một định danh, định
nghĩa dạng chuẩn Backus-Naur cho văn phạm phi ngữ cảnh.
Thập kỷ 1960s và 1970s chứng kiến sự ra đời của một loạt ngôn ngữ lập trình,
đáng chú ý nhất là Pascal (1970) và C (1972). Chúng đều là ngôn ngữ đa năng,
nhưng Pascal chủ yếu dùng để viết các chương trình ứng dụng, mô tả thuật toán
trên các ấn bản khoa học còn C thường được coi là ngôn ngữ lập trình hệ thống vì
đó chính là ngôn ngữ xây dựng nên hệ điều hành Unix. Giai đoạn này cũng có một
3
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
sự tranh luận sôi nổi về tính hiệu quả của phương pháp lập trình cấu trúc
(structured programming) - một kỹ thuật lập trình nhằm tăng tính rõ ràng, chất
lượng của chương trình và giảm thời gian phát triển chương trình bằng cách sử
dụng các lệnh chọn, lệnh lặp với cấu trúc khối và chương trình con. Tranh cãi xoay
quanh vấn đề sử dụng lệnh nhảy goto, một bộ phận đáng kể lập trình viên cho rằng
sử dụng lệnh nhảy goto là một phong cách lập trình tồi. Tuy vậy trong một vài
trường hợp, việc dùng các cấu trúc khác để thay thế lệnh nhảy goto lại làm phức
tạp hóa chương trình. Một số ngôn ngữ lập trình cấu trúc những vẫn cho phép sử
dụng lệnh nhảy goto nhưng kèm theo những khuyến cáo khi sử dụng.
Thập kỷ 1980s, 1990s chứng kiến sự hợp nhất tương đối của các ngôn ngữ lập
trình. Thay vì phát minh ra những ngôn ngữ lập trình mới, các ý tưởng tốt được
các ngôn ngữ lập trình học tập lẫn nhau và nâng cấp lên thành các phiên bản mới
của ngôn ngữ. Mô hình lập trình hướng đối tượng (Object-Oriented Programming
- OOP) cho bổ sung nhiều kỹ thuật mới cho phương pháp lập trình cấu trúc. Những
đặc tả OOP được tích hợp vào trong ngôn ngữ C, Pascal thành ngôn ngữ C++ và
Object Pascal (Delphi). Thời gian này cũng đánh dấu sự ra đời của ngôn ngữ Java:
Một kiến trúc ngôn ngữ lập trình và cả trình dịch hoàn toàn độc lập với phần cứng,
chạy được trên mọi kiến trúc có trang bị máy ảo Java.
Trong kỷ nguyên hiện tại, đã có nhiều ngôn ngữ lập trình trở nên lạc hậu và không
còn sử dụng nữa, bên cạnh đó có nhiều ngôn ngữ lập trình mới được thiết kế. Ta
có thể tạm phân loại các ngôn ngữ lập trình ra thành các nhóm như sau:
Nhóm ngôn ngữ dùng để học nhập môn lập trình như Scratch, Python, Pascal, …
đây là những ngôn ngữ dễ học ở mức độ cơ bản. Chúng thích hợp cho những người
chỉ cần biết khái niệm lập trình, hoặc chỉ có nhu cầu làm những chương trình đơn
giản. Với định hướng trở thành lập trình viên chuyên nghiệp, cũng có thể học
chúng đến mức độ nào đó rồi chuyển sang học ngôn ngữ khác.
Nhóm ngôn ngữ lập trình đa năng (general-purpose programming languages) như
Object Pascal, C, C++, C#, Java, … : Đây là nhóm ngôn ngữ nền tảng và quan trọng
nhất mà bất kỳ lập trình viên chuyên nghiệp nào cũng phải biết ít nhất một ngôn
ngữ. Nhóm ngôn ngữ này được chuẩn hóa ISO, trình dịch liên tục được tối ưu và
nâng cấp để chạy được trên nhiều kiến trúc phần cứng khác nhau. Học ngôn ngữ
lập trình đa năng cho phép lập trình viên có thể viết ra các chương trình hiệu suất
cao, không chịu bất kỳ sự giới hạn nào của ngôn ngữ, ngoài ra còn có thể hiểu được
những bí quyết tối ưu mã lệnh.
Nhóm ngôn ngữ lập trình chuyên biệt (special-purpose programming languages):
Đây là những ngôn ngữ thiết kế riêng cho một vài nhiệm vụ cụ thể, chẳng hạn ngôn
4
Lê Minh Hoàng
Chương 1
Lập trình và ngôn ngữ lập trình
ngữ R dùng trong các phần mềm thống kê, MATLAB dùng cho các công cụ xử lý
toán học, LISP dùng trong các ứng dụng trí tuệ nhân tạo. Những chương trình viết
trên những ngôn ngữ này xử lý một vài tác vụ chuyên biệt rất dễ dàng và hiệu quả
nhờ cấu trúc của ngôn ngữ và hệ thống thư viện đi kèm, nhưng với những tác vụ
khác có thể rất khó hoặc không thể thực hiện được.
Nhóm ngôn ngữ kịch bản (scripting languages): Đây là những ngôn ngữ dùng để
viết những đoạn mã không phải để dịch và chạy trực tiếp trên máy, chúng chứa
một “kịch bản” cho một phần mềm khác thực hiện. Chẳng hạn như Java Script để
các trình duyệt thực hiện ngay trên trang Web hay VB Script để viết các Macro
trong Microsoft Office.
1.2. Vài nét về ngôn ngữ lập trình C++
Vào cuối những năm 1960s, Dennis M. Ritchie và Ken Thompson từ phòng thí
nghiệm Bell của công ty AT&T phát triển hệ điều hành UNIX cho các máy tính lớn.
Ban đầu, hệ điều hành này được viết hoàn toàn bằng hợp ngữ. UNIX tích hợp sẵn
những trình dịch cho hợp ngữ, FORTRAN và đặc biệt là ngôn ngữ B, ngôn ngữ được
chính Ken Thompson phát triển. Ngôn ngữ B được sử dụng để thay thế cho hợp
ngữ trong quá trình phát triển tiếp theo của hệ điều hành UNIX, bởi nó cho phép
viết những đoạn mã ngắn gọn hơn và dễ dàng hơn nhiều so với hợp ngữ.
Nhược điểm của ngôn ngữ B là nó không có cơ chế định kiểu, không có khái niệm
dữ liệu có cấu trúc. Đó là lý do mà Ritchie tiếp tục phát triển ngôn ngữ B thành một
ngôn ngữ mới đặt tên là C, giữ lại hầu hết cú pháp của B nhưng đưa thêm vào các
kiểu dữ liệu mới và một vài sự thay đổi (1971-1973). Ngôn ngữ C có sức mạnh của
một ngôn ngữ bậc cao nhưng lại dễ dàng tích hợp những đoạn mã hợp ngữ, rất
thuận lợi cho việc viết hệ điều hành và cả các chương trình phần mềm. Rất nhiều
thành phần của UNIX sau đó được viết bằng C, bản thân phần cơ sở (kernel) của
UNIX cũng được viết lại hoàn toàn bằng C (1973).
Ngôn ngữ C được viết ra trong một cuốn sách kinh điển: “The C Programming
Language”, bởi Kernighan và Ritchie và từ đó phổ biến khắp thế giới. Viện tiêu
chuẩn quốc giá Mỹ (American National Standards Institute – ANSI) đã chuẩn hóa*
ngôn ngữ C với một số sửa đổi nhỏ, trở thành ANSI C (1983) và sau đó cả tổ chức
tiêu chuẩn quốc tế (International Standards Organization – ISO) cũng chuẩn hóa
*Chuẩn hóa là đưa ra những tiêu chuẩn, quy định bắt buộc cho ngôn ngữ nhằm giúp cho những
chương trình viết trên ngôn ngữ được chuẩn hóa có thể dịch được và chạy được trên tất cả môi
trường tuân theo tiêu chuẩn đã định. Mặc dù công cụ lập trình cụ thể có thể có những mở rộng
thêm cho ngôn ngữ, nhưng chúng ta không dùng các thành phần không chuẩn hóa trong lúc học để
có thể dễ dàng chuyển đổi sang môi trường lập trình, chương trình dịch khác.
5
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
ngôn ngữ C. Mặc dù tên đúng bây giờ là ISO C, nhưng hầu hết mọi người vẫn quen
thuộc với từ ANSI C hơn.
Ngôn ngữ lập trình C++ được phát minh bởi Bjarne Stroustrup. Khi làm luận án
tiến sĩ, ông có cơ hội làm việc với ngôn ngữ Simula67, ngôn ngữ đầu tiên hỗ trợ
cho các mô hình lập trình hướng đối tượng (Object-Oriented Programming – OOP).
Stroupstrup nhận thấy OOP rất hữu dụng cho việc phát triển phần mềm, tuy nhiên
Simula quá chậm để sử dụng trên thực tế. Không lâu sau, ông bắt đầu nghiên cứu
mở rộng ngôn ngữ C với các tính năng hỗ trợ lập trình hướng đối tượng và cho ra
đời một ngôn ngữ mới: C with Classes.
Năm 1983, C with Classes được đổi tên thành C++ (toán tử ++ trong ngôn ngữ C là
toán tử tăng lên), rất nhiều tính năng mới được thêm vào ngôn ngữ như: phương
thức ảo, nạp chồng hàm, tham chiếu với ký hiệu &, từ khóa const, … Năm 1985,
Stroustrup viết cuốn sách “The C++ Programming Language”, mặc dù ngôn ngữ
chưa được chuẩn hóa, C++ đã trở nên phổ biến từ khi xuất bản cuốn sách này. Ngôn
ngữ được cập nhật một lần nữa vào năm 1989 với việc đưa thêm vào kiến trúc đa
thừa kế của các lớp.
Năm 1998, tổ chức tiêu chuẩn quốc tế đã chuẩn hóa C++: ISO/IEC 14882:1998 gọi
tắt là C++ 98, đặc biệt là thư viện chuẩn các lớp mẫu (Standard Template Library –
STL) cũng được chuẩn hóa. Tiêu chuẩn này có nhiều vấn đề và được chỉnh sửa lại
năm 2003 trong bản C++ 03.
Vào giữa năm 2011, chuẩn C++ 11 được công bố, trong đó đưa vào một số thành
phần của thư viện Boost và rất nhiều sự nâng cấp của ngôn ngữ: hỗ trợ xử lý biểu
thức chính quy (regular expression), thư viện thuật toán tạo số ngẫu nhiên, thư
viện đồng hồ thời gian thực, cú pháp vòng lặp mới, hàm lambda… Chuẩn này được
hoàn thiện hơn với C++14.
Mặc dù các chuẩn có tính kế thừa, không hẳn một chương trình viết trong một
chuẩn C++ có thể dịch và chạy được với trình dịch cho một chuẩn khác. Hầu hết
các trình dịch hiện nay đều hỗ trợ C++14, những chuẩn C++ mới hơn như C++17,
C++20, … đôi khi chỉ được hỗ trợ một phần hoặc hiện tại chưa được hỗ trợ trong
các trình dịch. Chúng ta sẽ sử dụng trình dịch GNU GCC (cụ thể là G++) cho các ví
dụ trong cuốn sách này. Tiêu chuẩn C++ được sử dụng là ISO C++14.
6
Lê Minh Hoàng
Chương 2
Cấu trúc chương trình C++
Chương trình 1
1 | //Chương trình C++ đầu tiên Xin chao!
2 | #include <iostream>
3 |
4 | int main()
5 | {
6 | std::cout << "Xin chao!";
7 | }
Tuy đơn giản, chương trình cũng phải bao gồm các thành phần bắt buộc của một
chương trình C++.
Bên phải là kết quả in ra màn hình, các con số và dấu “|” bên trái mỗi dòng là chỉ số
dòng để tiện chỉ định, chúng không phải là thành phần của chương trình. Ta phân
tích từng dòng một về vai trò của chúng:
Dòng 1:
//Chương trình C++ đầu tiên
Hai dấu gạch cho biết phần còn lại trên dòng là chú thích (comment), những chú
thích được viết ra bởi lập trình viên, dùng để giải thích một đoạn mã hoặc một số
lưu ý quan trọng về đoạn mã. Chú thích không ảnh hưởng đến cách thức chương
trình thực hiện, chỉ có tác dụng cho người đọc mà thôi.
Dòng 2:
#include <iostream>
Các dòng bắt đầu bằng dấu # sẽ được trình biên dịch xử lý trước khi dịch. Trong
chương trình cụ thể này, chỉ thị #include <iostream> sẽ yêu cầu trình biên dịch
thêm vào một đoạn mã chuẩn (nằm trong file đề mục iostream) cho phép thực hiện
các lệnh nhập/xuất dữ liệu.
Dòng 3:
Đây là một dòng trống, không có hiệu lực trong chương trình, chỉ đơn giản là tăng
tính sáng sủa, dễ đọc.
Dòng 4:
int main()
Dòng này bắt đầu khai báo của một hàm. Hàm là một đoạn mã được đặt tên, trong
trường hợp này tên hàm là “main”. Chương trình C++ có thể chứa nhiều hàm, mỗi
7
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
hàm có kiểu kết quả trả về, tên hàm và các tham số. Ta sẽ trình bày về hàm trong
các chương sau.
Hàm main là một hàm đặc biệt trong chương trình C++, nó là hàm được gọi chạy
đầu tiên khi thực thi chương trình C++, bất kể hàm đó nằm ở đâu trong mã. Hàm
main có kiểu kết quả trả về là một giá trị số nguyên (int), trong đó giá trị trả về là
0 nếu chương trình kết thúc bình thường và giá trị trả về khác 0 nếu đó là mã lỗi
sinh ra trong lúc thực hiện. Hàm main trong chương trình này không có tham số,
phần tham số (trong dấu ngoặc đơn) được để trống.
Dòng 5 và 7:
Dấu “{” cho biết nơi bắt đầu thân hàm và dấu “}” cho biết nơi kết thúc hàm. Phần
nằm giữa hai dấu {…} chứa cách lệnh sẽ cần thực hiện khi gọi hàm.
Dòng 6:
std::cout << "Xin chao!";
Dòng này là một lệnh của C++, các lệnh được thi hành theo thứ tự xuất hiện trong
hàm. Lệnh cụ thể này có 3 thành phần:
std::cout, đối tượng chỉ thiết bị xuất chuẩn (standard character output device),
thông thường đây chính là màn hình máy tính.
Toán tử <<: chỉ ra rằng những gì tiếp theo sau sẽ được ghi ra (đẩy ra) thiết bị
xuất chuẩn.
Một đoạn ký tự trong dấu nháy kép (“…”) là nội dung được ghi ra thiết bị xuất
chuẩn.
Tất cả các lệnh đơn giản cần kết thúc bởi dấu chấm phẩy “;”, một trong những lỗi
cú pháp phổ biến nhất khi lập trình C++ là quên kết thúc lệnh bằng dấu chấm phẩy.
C++ không có các quy tắc nghiêm ngặt về số lệnh trên một dòng cũng như quy tắc
thụt lề (indent), sự phân chia các dòng cũng như cách thức viết thụt lề là để chương
trình dễ hiểu với người đọc.
Ta thêm một lệnh vào chương trình:
Chương trình 2
1 | //Chương trình C++ thứ hai Xin chao! Day la C++
2 | #include <iostream>
3 |
4 | int main()
5 | {
6 | std::cout << "Xin chao! ";
7 | std::cout << "Day la C++";
8 | }
8
Lê Minh Hoàng
Chương 2
Cấu trúc chương trình C++
Chương trình Hello2.cpp thực hiện ghi ra thiết bị xuất chuẩn 2 lần bằng 2 lệnh khác
nhau. Các lệnh được thi hành theo đúng thứ tự xuất hiện trong thân hàm.
Nhắc lại rằng việc phân tách các lệnh ra trên các dòng khác nhau cũng như quy tắc
thụt lề chỉ là để làm cho chương trình dễ đọc đối với con người, không ảnh hưởng
đến cách thức thực hiện. Chẳng hạn ta có thể viết lại chương trình như sau:
1 | //Chương trình C++ thứ hai
2 | #include <iostream>
3 | int main(){std::cout << "Xin chao! "; std::cout << "Day la C++";}
Chú ý là ở chương trình trên, ta không thể ghép dòng 2 vào cuối dòng 1, bởi nếu
vậy toàn bộ nội dung dòng 2 sẽ trở thành chú thích. Ta cũng không thể ghép dòng
3 vào cuối dòng 2, các chỉ thị tiền xử lý (bắt đầu bằng dấu #) không phải là câu lệnh
(nên chúng không kết thúc bởi dấu chấm phẩy), chúng cần viết riêng biệt trên từng
dòng, được trình biên dịch đọc lần lượt và xử lý trước khi quá trình biên dịch bắt
đầu.
Thậm chí, mỗi lệnh của C++ cũng có thể tách ra trên nhiều dòng liên tiếp nếu việc
cắt dòng không vi phạm cú pháp của lệnh:
1 | //Chương trình C++ thứ hai
2 | #include <iostream>
3 | int main()
4 | {
5 | std::cout
6 | << "Xin chao! ";
7 | std::cout
8 | << "Day la C++";
9 | }
2.2. Chú thích
Chú thích là phần văn bản không có ý nghĩa với trình dịch, chỉ dùng để ghi chú
những lưu ý quan trọng hoặc mô tả vắn tắt ý nghĩa của đoạn lệnh. C++ cho phép
hai cách viết chú thích:
Cách 1: //Chú thích
Cách viết này coi toàn bộ phần văn bản bắt đầu từ hai dấu // đến cuối dòng là chú
thích. Phần văn bản này nằm trên một dòng.
Cách 2: /*Chú thích*/
Cách viết này coi toàn bộ phần văn bản bắt đầu từ cặp ký tự /* tới cặp ký tự */ là
chú thích. Phần văn bản này có thể nằm trên nhiều dòng liên tiếp.
1 | /* Chương trình C++
2 | với nhiều chú thích */
3 | #include <iostream>
4 | int main()
5 | {
6 | std::cout << "Xin chao! "; //In ra: Xin chào!
7 | std::cout << "Day la C++"; //In ra: Đây là chương trình C++
8 | }
9
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
Hello2.cpp ✓
1 | #include <iostream>
2 | using namespace std; //Dùng không gian tên std
3 |
4 | int main()
5 | {
6 | cout << "Xin chao! "; //Không cần viết std::cout
7 | cout << "Day la C++"; //Không cần viết std::cout
8 | }
Việc sử dụng không gian tên có thể hiểu qua ví dụ sau:
Lớp Mầm có một bạn tên là A; lớp Lá cũng có một bạn tên A. Khi tất cả các lớp đứng
dưới sân trường, muốn gọi bạn A ở lớp Lá, ta phải chỉ định đầy đủ “Bạn A ở lớp
Lá”. Tuy nhiên khi đang ở trong lớp Lá, ta chỉ cần gọi bạn A là đủ (vì coi như ta đã
sử dụng không gian tên của lớp Lá trong trường hợp này rồi).
Để chương trình được ngắn gọn và đơn giản, các ví dụ sau này tôi sẽ sử dụng không
gian tên std khá thường xuyên. Mặc dù vậy, khi viết các chương trình lớn, việc sử
dụng không gian tên std dễ gây ra xung đột về tên trong thư viện chuẩn và tên do
người lập trình tự định nghĩa. Một số chuẩn công nghiệp còn không cho phép dùng
lệnh “using namespace std;”. Chúng ta sẽ thảo luận chi tiết hơn về cách dùng cũng
như xử lý xung đột ở các chương sau.
2.4. Bộ chữ viết của C++
Bộ chữ viết của C++ gồm có:
Các chữ cái hoa (A…Z), các chữ cái thường (a…z), dấu gạch nối dưới (_) cũng
được coi là chữ cái.
Các chữ số (0…9)
Các ký tự đơn: + - * / = < > [ ] . , ( ) : ^ @ { } $ # % &
Các cặp ký tự: !=; <=; >=; ==; +=; -=; *=; /=; /*; */; //;…
Dấu cách (hay dấu trống) để ngăn cách các từ
Dấu chấm phẩy “;” để ngăn cách các lệnh
10
Lê Minh Hoàng
Chương 2
Cấu trúc chương trình C++
Trên đây là những ký tự liên quan tới cú pháp của C++, còn các ký tự trong chú
thích, hằng xâu ký tự thì không có ràng buộc gì cả.
Một điều quan trọng cần lưu ý: C++ có phân biệt chữ hoa và chữ thường. Hai tên
(định danh) khác nhau về ký tự hoa/thường được coi là khác nhau. Chẳng hạn cout
và CoUt là hai tên hoàn toàn phân biệt.
11
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
12
Lê Minh Hoàng
Chương 3
Cài đặt Code::Blocks
13
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
14
Lê Minh Hoàng
Chương 3
Cài đặt Code::Blocks
15
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
16
Lê Minh Hoàng
Chương 3
Cài đặt Code::Blocks
17
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
Biên dịch:
Để dịch chương trình, ta bấm Ctrl+F9
(hoặc vào menu Build/Build).
Thao tác này thực hiện kiểm tra lỗi cú
pháp:
Nếu có lỗi cú pháp, trong cửa sổ
Build messages sẽ hiện mô tả về
lỗi, ta cần sửa và dịch lại.
Nếu không có lỗi cú pháp, chương
trình dịch sẽ sinh mã máy tương
ứng, trong cửa số Build log sẽ hiện thông báo dịch thành công. Mã khả thi đã
được sinh ra (trong trường hợp này là file ABC.exe nằm trong thư mục chứa
dự án: D:\Work)
Chú ý là mỗi khi có sự sửa đổi mã nguồn, ta cần dịch lại để kiểm tra lỗi cú pháp
cũng như sinh ra mã máy cập nhật với mã nguồn mới.
18
Lê Minh Hoàng
Chương 3
Cài đặt Code::Blocks
Chạy:
Để chạy chương trình, ta bấm
Ctrl+F10 (hoặc vào menu Build/ Run)
Cũng có thể bấm F9 (hoặc vào menu
Build/ Build and run), chức năng này kiêm luôn 2 công đoạn: Dịch và chạy.
Sau khi chương trình thực hiện xong, nếu thấy thông báo “Process return 0” tức là
chương trình kết thúc bình thường, không có lỗi (mã lỗi trả về cho hệ điều hành là
0). Nếu mã lỗi trả về khác 0, tức là chương trình có lỗi lúc chạy và bị kết thúc bất
bình thường.
Chương trình chạy xong, ta bấm một phím bất kỳ để kết thúc chương trình và quay
về màn hình Code∷Blocks. Chương trình cần phải kết thúc thì chúng ta mới có thể
chạy lại hoặc viết chương trình mới.
19
Ngôn ngữ lập trình C++
Phần I
GIỚI THIỆU C++
20
Lê Minh Hoàng
Chương 3
Cài đặt Code::Blocks
lỗi cho nó. Việc sử dụng thành thạo các chức năng của trình gỡ rối kết hợp với các
cách thức gỡ rối thủ công sẽ làm giảm chi phí về thời gian và công sức hoàn thiện
chương trình.
21
Ngôn ngữ lập trình C++
Phần II.
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Phần này trình bày những thành tố cơ bản của ngôn ngữ C++, bao gồm các khái
niệm tên, biến, hằng, các toán tử, lệnh nhập xuất dữ liệu …
Định kiểu mạnh là một trong những đặc trưng của các ngôn ngữ lập trình và trình
biên dịch tốc độ cao, bao gồm cả C++. Định kiểu mạnh bao gồm những quy tắc
nghiêm ngặt khi khai báo, gán giá trị và viết biểu thức, điều này giúp cho trình
biên dịch sinh mã tính toán tối ưu nhất có thể.
Nội dung của phần này cung cấp cho các bạn những hiểu biết về kiểu dữ liệu, các
phép toán trên dữ liệu và cách tổ chức dữ liệu trong bộ nhớ của C++, nắm rõ những
kiến thức đó sẽ giúp bạn viết chương trình hiệu quả hơn, ít lỗi hơn và chuẩn hơn.
23
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
24
Lê Minh Hoàng
Chương 4
Biến và kiểu
C++ trên các thiết bị khác chỉ dựa vào 31 ký tự đầu tiên để so sánh tên, vì vậy không
nên đặt tên dài hơn 31 ký tự.
Từ khóa là một số từ đặc biệt, chúng được sử dụng vào mục đích riêng của ngôn
ngữ: trong các cấu trúc điều khiển, mô tả dữ liệu cấu trúc, … Tên do lập trình viên
đặt không được trùng với các từ khóa này. Chẳng hạn các từ khóa using,
namespace, if, else, switch, while, do, for, struct, class, void, return, … Danh mục
các từ khóa rất nhiều, ta có thể tham khảo ở mục 4.4.1.
4.2. Các kiểu dữ liệu cơ sở
Giá trị của các biến được lưu trữ ở đâu trong bộ nhớ là do chương trình dịch bố trí.
Trong ngôn ngữ lập trình cấp thấp, ta phải tự bố trí và biết địa chỉ vùng nhớ này
mới có thể đặt dữ liệu vào đó. Trong ngôn ngữ lập trình bậc cao, ta không cần biết
vùng nhớ này nằm ở đâu, gọi tên của biến tức là truy cập vùng nhớ tương ứng.
Tuy nhiên, dữ liệu lưu trong bộ nhớ chỉ là các bit (chữ số nhị phân – BInary digiT),
điều duy nhất chương trình cần phải nhận thức được là loại dữ liệu lưu trữ tại một
vùng nhớ để có thể diễn giải giá trị đó và đưa ra những phép xử lý phù hợp với dữ
liệu. Việc này đưa đến khái niệm về kiểu dữ liệu (data type) của biến.
Chẳng hạn với phép chia biến 𝑎 cho biến 𝑏 (𝑎/𝑏), nếu 𝑎 và 𝑏 là hai biến thuộc kiểu
số nguyên, phép chia này sẽ được diễn giải thành phép chia lấy phần nguyên
(17/2 = 8), còn nếu 𝑎 và 𝑏 là hai biến thuộc kiểu số thực, phép chia này được diễn
giải thành phép chia lấy kết quả số thực (17/2 = 8.5).
Các kiểu dữ liệu cơ sở được ngôn ngữ C++ hỗ trợ trực tiếp bao gồm 4 loại:
Kiểu số nguyên: Có nhiều kiểu dữ liệu dùng để lưu trữ các số nguyên, mỗi kiểu
có thể biểu diễn các số nguyên trong một phạm vi nào đó. Kiểu có kích thước
càng lớn thì phạm vi biểu diễn càng rộng, có kiểu biểu diễn được số âm, có kiểu
chỉ biểu diễn các số không âm.
Kiểu số thực: Cũng có nhiều kiểu dữ liệu biểu diễn số thực, chúng được đặc
trưng bởi kích thước, độ chính xác cũng như phạm vi biểu diễn.
Kiểu ký tự: Những kiểu dữ liệu này để biểu diễn ký tự tùy theo bảng mã ký tự
được sử dụng, chẳng hạn các chữ cái, chữ số, các ký tự như ‘$’, ‘&’, ‘#’,…, Kiểu
ký tự phổ biến nhất mà chúng ta dùng để học tập là kiểu char, biểu diễn các ký
tự trong bảng mã ASCII (American Standard Code for Information Interchange).
Kiểu logic boolean: Trong C++, kiểu này có tên là bool, dùng để biểu diễn hai
giá trị chân lý: true (Đúng) và false (Sai).
Để thuận lợi cho việc viết và ước lượng giá trị hằng số. Ta dùng dấu chấm “.” để
phân tách phần nguyên và phần thập phân, một vài trường hợp dùng dấu phẩy “,”
25
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
để phân tách các hàng nghìn. Điều này không đúng với ký pháp của Việt Nam*
nhưng lại khớp với ngôn ngữ lập trình, tránh sự nhập nhằng không cần thiết.
Ví dụ: 𝜋 ≈ 3.141593, 1 tỉ = 1,000,000,000
Trên các máy tính hiện nay và trong rất nhiều tài liệu, kể cả trong tài liệu này, ta
quy ước 1 byte tương ứng với một dãy 8 bit. Mặc dù khái niệm bit và byte có tính
độc lập tương đối:
bit: Là đơn vị nhỏ nhất của thông tin, biểu diễn 2 trạng thái, ký hiệu 0 và 1
byte: Là đơn vị truy cập thông tin số hóa, tức là dù ta chỉ có nhu cầu truy cập
một bit riêng lẻ, thiết bị lưu trữ (bộ nhớ, đĩa, …) vẫn phải truy cập cả 1 byte
chứa bit đó. Byte là một dãy bit liền nhau, độ dài dãy bit tùy theo máy tính. Tuy
vậy, hầu như tất cả các máy tính hiện nay đều dùng byte là dãy gồm 8 bit nên
quan niệm 1 byte = 8 bit trở thành tiêu chuẩn không chính thức. Trong các kiểu
dữ liệu ta trình bày tiếp theo, kiểu dữ liệu nào cũng có kích thước là một số chia
hết cho 8, chính là do tiêu chuẩn không chính thức nói trên.
4.2.1. Các kiểu số nguyên
C++ có rất nhiều kiểu số nguyên, kiểu số nguyên phổ biến nhất có tên là int, trong
chương trình dịch với chuẩn C++14 mà ta sử dụng, mỗi biến kiểu int chiếm 32 bit
và có thể chứa giá trị số nguyên thuộc phạm vi [−231 ; 231 − 1]. Những kiểu số
nguyên khác hay được sử dụng là unsigned, long long, unsigned long long.
Kiểu int và long long thuộc kiểu số nguyên có dấu (biểu diễn được cả số âm và số
dương – signed), kiểu unsigned và unsigned long long thuộc kiểu số nguyên không
dấu (chỉ biểu diễn những số nguyên không âm - unsigned)
Tên kiểu và phạm vi biểu diễn Kích thước
int
-231…231-1 32 bit
-2,147,483,648 … 2,147,483,647
unsigned
0 … 232-1 32 bit
0 … 4,294,967,295
long long
-263 … 263-1 64 bit
-9,223,372,036,854,775,808 … 9,223,372,036,854,775 807
unsigned long long
0 … 264-1 64 bit
0 … 18,446,744,073,709,551,615
*Ở Việt Nam dùng dấu phẩy để phân tách phần nguyên và phần thập phân, dùng dấu chấm hoặc
dấu cách để phân tách hàng nghìn
26
Lê Minh Hoàng
Chương 4
Biến và kiểu
Những mô tả kiểu trong bảng trên chỉ là tương ứng với chương trình dịch sử dụng
trong cuốn sách này, không phải chuẩn C++ ISO Standard. Chi tiết về các chuẩn số
nguyên theo C++ ISO Standard có thể tra cứu trong mục 4.4.2.
4.2.2. Các kiểu số thực
Số thực trong C++ biểu diễn dưới dạng dấu chấm động (floating-point). Cách biểu
diễn này tách ô nhớ số thực ra làm 2 phần: Phần thứ nhất biểu diễn một số nguyên
𝑎 gọi là phần chữ số có nghĩa, phần thứ hai biểu diễn một số nguyên 𝑏 gọi là phần
mũ. Khi đó giá trị của số thực là 𝑎 × 10𝑏 (ký hiệu 𝑎e𝑏).
Chẳng hạn nếu 𝑎 = 123456789 và 𝑏 = −5 thì giá trị số thực là: 1234.56789
Một đặc trưng quan trọng đối với kiểu số thực là số chữ số có nghĩa khi thực hiện
tính toán trên kiểu số thực đó, số chữ số có nghĩa này được đặc trưng bởi phần bit
biểu diễn số nguyên 𝑎. Chẳng hạn một kiểu số thực có 16 chữ số có nghĩa thì khi
tính toán với những số có giá trị tuyệt đối quá lớn hoặc quá nhỏ, vẫn chỉ có 16 chữ
số đầu tiên trong biểu diễn thập phân là “tin được” mà thôi. Ngoài đặc trưng về số
chữ số có nghĩa, phạm vi có thể biểu diễn của các kiểu số thực rất rộng nên có thể
không cần quan tâm.
C++ cung cấp ba kiểu số thực: float, double và long double. Số chữ số có nghĩa
của kiểu double không ít hơn so với float và số chữ số có nghĩa của kiểu long double
không ít hơn số chữ số có nghĩa của long double. Chi tiết cụ thể về ba kiểu dữ liệu
này trong G++ có thể tra cứu trong mục 4.4.3.
4.2.3. Kiểu ký tự
Kiểu ký tự có thể xử lý hiệu quả nhất là kiểu char. Mỗi biến kiểu char chiếm đúng
1 byte (trên máy tính hiện nay là 8 bit). Kiểu ký tự dùng để biểu diễn dữ liệu gồm
1 ký tự riêng lẻ trong một bảng mã: Chữ cái, chữ số, các dấu… Chi tiết về các kiểu
ký tự khác có thể tra cứu trong mục 4.4.4
4.2.4. Kiểu logic boolean
Kiểu logic boolean trong C++ có tên là bool, dùng để lưu trữ và tính toán các biểu
thức logic mệnh đề. Kiểu này chỉ có hai giá trị: false (sai) và true (đúng).
Không có quy định tiêu chuẩn về cách thức lưu trữ kiểu bool, tuy nhiên trong hầu
hết các chương trình dịch, kiểu boolean chiếm đúng 1 byte (8 bit).
4.2.5. Các kiểu dữ liệu đặc biệt
Kiểu void là kiểu có miền giá trị rỗng. Kiểu này được dùng trong vài trường hợp,
chẳng hạn hàm trả về kiểu void tức là hàm không cần trả về bất cứ giá trị nào.
Kiểu std∷nullptr_t hay decltype(nullptr) là kiểu dữ liệu đặc biệt dành riêng cho
con trỏ nullptr.
27
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
28
Lê Minh Hoàng
Chương 4
Biến và kiểu
Cách khai báo decltype có ích khi ta có nhiều biến bắt buộc phải cùng kiểu, khi đó
như trong ví dụ trên, chỉ cần sửa kiểu của biến 𝑥 thành double chẳng hạn thì kiểu
của biến 𝑦 cũng trở thành double.
4.3.4. Kiểu xâu ký tự
Trong chương trình Hello.cpp, trong lệnh:
cout << "Xin chao!";
"Xin chao!" thực ra là một xâu gồm 9 ký tự (tính cả dấu cách và dấu chấm than).
Xâu ký tự là một dãy các ký tự liên tiếp, thực ra đây là một kiểu dữ liệu phức hợp
mà ta sẽ trình bày kỹ hơn sau này trong một chương riêng nói về kiểu string.
4.4. Tra cứu
4.4.1. Danh mục các từ khóa
Danh mục từ khóa (keywords hay reserved words) được định nghĩa khác nhau
trong mỗi chuẩn C++. Tất cả những từ sau đều phải coi là từ khóa dù trong chuẩn
C++14 ta dùng, có thể nó chưa được coi là từ khóa. Điều này để hạn chế những
xung đột khi nâng cấp chương trình lên những chuẩn C++ mới hơn:
alignas, alignof, and, and_eq, asm, atomic_cancel, atomic_commit,
atomic_noexcept, auto, bitand, bitor, bool, break, case, catch, char, char8_t,
char16_t, char32_t, class, compl, concept, const, consteval, constexpr, const_cast,
continue, co_await, co_return, co_yield, decltype, default, delete, do, double,
dynamic_cast, else, enum, explicit, export, extern, false, float, for, friend, goto, if,
inline, int, long, mutable, namespace, new, noexcept, not, not_eq, nullptr, operator,
or, or_eq, private, protected, public, reflexpr, register, reinterpret_cast, requires,
return, short, signed, sizeof, static, static_assert, static_cast, struct, switch,
synchronized, template, this, thread_local, throw, true, try, typedef, typeid,
typename, union, unsigned, using, virtual, void, volatile, wchar_t, while, xor,
xor_eq.
Nhớ hết danh mục từ khóa là một việc khó, các hệ soạn thảo chuyên dụng cho C++
(như Code∷Blocks) có cơ chế làm nổi bật từ khóa bằng các màu sắc hoặc chữ đậm
để lập trình viên có thể tránh khai báo tên trùng với từ khóa.
4.4.2. Các kiểu số nguyên trong C++
Các kiểu số nguyên kích thước cố định
Đây là những kiểu số nguyên mà kích thước cũng như phạm vi biểu diễn của chúng
không thay đổi trên tất cả các cấu hình phần cứng, hệ điều hành và chương trình
dịch (nếu chúng được hỗ trợ trên nền tảng đó). Các kiểu số nguyên này đi theo
từng cặp, mỗi cặp gồm 2 kiểu cùng kích thước, một kiểu có dấu (có thể biểu diễn
số âm), một kiểu không dấu (chỉ biểu diễn số không âm).
29
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
*Hiện nay hai kiểu số nguyên kích thước cố định: int128_t và uint128_t đã được đề nghị nhưng
chưa được chuẩn hóa, chúng tồn tại trên chương trình dịch G++ 64 bit nhưng với một tên gọi khác
30
Lê Minh Hoàng
Chương 4
Biến và kiểu
Mặc dù quy ước như vậy, chương trình dịch vì các lý do kỹ thuật khác nhau chưa
chắc đã tuân thủ quy tắc này. Chẳng hạn với phiên bản G++ trên Windows, kiểu
int_leastλ_t cũng như kiểu int_fastλ_t cũng đều được ánh xạ thành kiểu intλ_t. Có
thể do G++ quan niệm rằng kiểu số nguyên càng nhỏ tính cành nhanh, mặc dù trên
thực tế không hẳn như vậy.
Ngoài ra có hai kiểu số nguyên “rộng” nhất:
Kiểu intmax_t là kiểu số nguyên có dấu, kích thước lớn nhất và phạm vi biểu
diễn rộng nhất trong tất cả các kiểu số nguyên có dấu mà nền tảng lập trình có
hỗ trợ. Phạm vi biểu diễn của kiểu này từ INTMAX_MIN đến INTMAX_MAX.
Kiểu uintmax_t là kiểu số nguyên không dấu, kích thước lớn nhất và phạm vi
biểu diễn rộng nhất trong tất cả các kiểu số nguyên không dấu mà nền tảng lập
trình có hỗ trợ. Phạm vi biểu diễn của kiểu này từ 0 đến UINTMAX_MAX.
Phiên bản G++ hiện tại trên Windows ánh xạ intmax_t thành int64_t, ánh xạ
uintmax_t thành uint64_t.
Các kiểu số nguyên kích thước cố định, các kiểu số nguyên phụ thuộc nền tảng lập
trình, cũng như các hằng số như INT16_MIN hay UINT64_MAX được khai báo trong
thư viện cstdint. Ta chỉ cần thêm lệnh tiền xử lý:
#include <cstdint>
vào chương trình là dùng được. Đôi khi thư viện này đã được nạp gián tiếp trong
quá trình nạp thư viện khác, ta vẫn có thể dùng dù quên không nạp thư viện cstdint
(điều này không được khuyến khích).
Các kiểu số nguyên chuẩn của C++
Gọi là các kiểu số nguyên chuẩn có vẻ không được hợp lý, tất cả các kiểu số nguyên
ta trình bày trước đây đều đã được chuẩn hóa với ý nghĩa và mục đích sử dụng rõ
ràng của từng kiểu. Những kiểu số nguyên nói đến trong mục này thực ra là kế
thừa của một quá trình phát triển không được chuẩn hóa, việc cố gắng giữ lại tính
tương thích với những phiên bản đầu tiên dẫn đến các tên gọi dài dòng, khó nhớ
và thiếu nhất quán. Tuy nhiên chúng lại được sử dụng nhiều nhất để giữ tính tương
thích giữa những phiên bản C++, có thể sử dụng ngay mà không cần nạp bất cứ thư
viện nào.
31
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
unsigned short int Không Kích thước bằng signed short int uint16_t
unsigned long int Không Kích thước bằng signed long int uint32_t
unsigned long long int Không Kích thước bằng signed long long int uint64_t
Cột 1 của bảng trên là tên kiểu đầy đủ, thực ra chỉ cần viết phần in đậm, chẳng
hạn kiểu “signed short int” có thể viết là “signed short”, “short int” hoặc đơn
giản là “short”.
Kiểu char được quy định là chính xác 1 byte, hầu hết các máy tính đều sử dụng
đơn vị 1 byte bằng 8 bit. Tuy có những máy tính sử dụng đơn vị 1 byte bằng 6,
7 hay 9 bit… nhưng những máy này không còn được sử dụng và cũng chưa bao
giờ có trình dịch C++ cho chúng nên ta có thể coi 1 byte luôn được tạo thành từ
8 bit.
Các kiểu cũng chia làm từng cặp, cặp kiểu sau có kích thước lớn hơn, phạm vi
biểu diễn rộng hơn cặp kiểu trước.
Cột cuối cùng là kiểu số nguyên kích thước cố định mà G++ ánh xạ vào.
Có một chú ý là kiểu int (signed int) và kiểu unsigned (unsigned int) luôn được ánh
xạ vào thành cặp kiểu số nguyên mà việc tính toán và lưu trữ thuận lợi nhất trên
máy tính. Chuẩn C++ quy định kích thước hai kiểu này ít nhất 16 bit, hầu hết các
chương trình dịch hiện nay đều dùng kiểu int và unsigned kích thước 32 bit.
int = int32_t: −231 … 231 − 1 (−2,147,483,648 … 2,147,483,647)
unsigned = uint32_t: 0 … 232 − 1 (0 … 4,294,967,295)
32
Lê Minh Hoàng
Chương 4
Biến và kiểu
Mặc dù không có lý do gì để ánh xạ kiểu int trở lại thành int16_t trên các nền tảng
từ nay về sau, để lưu trữ giá trị 1 tỉ, một số lập trình viên vẫn cẩn thận dùng kiểu
long thay vì int.
Tất cả những gì không được quy ước rõ ràng trong chuẩn thì rất có thể bị thay đổi
trong nền tảng lập trình khác. Hiện tại, các kiểu int, long, int32_t là một, nhưng điều
này không được đảm bảo trong mọi thế hệ máy tính, hệ điều hành cũng như
chương trình dịch. Muốn hạn chế rắc rối khi chuyển chương trình sang dịch ở nền
tảng lập trình khác, ta nên coi chúng là những kiểu khác nhau*. Tương tự như vậy,
kiểu int64_t hiện nay cũng là int_least64_t, int_fast64_t, intmax_t, long long, nhưng
chúng phải được coi như những kiểu khác nhau khi lập trình.
Đây là kinh nghiệm khi sử dụng các kiểu số nguyên:
Khảo sát mình viết phần mềm trên nền tảng nào, dùng trình dịch nào, cụ thể
trên môi trường đó các kiểu số nguyên chuẩn kích thước bao nhiêu, phạm vi
biểu diễn thế nào.
Dùng kiểu int và unsigned nếu đáp ứng được yêu cầu.
Dùng kiểu long long và unsigned long long nếu muốn biểu diễn số nguyên
lớn hơn kiểu int và unsigned. Một lựa chọn khác chắc chắn hơn là dùng
intmax_t và uintmax_t.
Khi cần tiết kiệm bộ nhớ hay yêu cầu số nguyên phải có số bit định sẵn (chẳng
hạn các vấn đề xử lý bit), dùng các kiểu số nguyên kích thước cố định mà vẫn
đáp ứng được phạm vi cần biểu diễn.
Những kiểu số nguyên còn lại, khi cần ta có thể tra cứu tài liệu về cách dùng.
Nguyên lý biểu diễn bù 2
Mọi thông tin lưu trên máy tính đều phải được chuyển hóa thành các bit (BInary
digiT), kiểu số nguyên int8_t và uint8_t đều chiếm 8 bit, nhưng miền giá trị biểu
diễn của hai kiểu này khác nhau, tức là sẽ có hai giá trị khác nhau trên hai kiểu
được biểu diễn bởi cùng một dãy bit.
*Khoảng những năm 1980 - 1990, với những máy tính cũ, hệ điều hành DOS, chương trình dịch
Turbo C, kiểu int được ánh xạ thành int16_t. Hầu hết các chương trình dịch hiện nay đều ánh xạ int
thành int32_t. Cũng có thể trong tương lai, kiểu int được ánh xạ thành int64_t. Vì vậy ta chỉ được
quan niệm int là kiểu số nguyên có dấu mà máy có thể tính toán và lưu trữ nhanh nhất (bằng kích
thước các thanh ghi tính toán của CPU). Kích thước mỗi biến int, phạm vi biểu diễn của kiểu int đều
không được định chuẩn.
Mặc dù vậy, theo xu hướng kích thước thanh ghi của CPU ngày càng lớn lên chứ không nhỏ đi qua
các thế hệ, ta có thể coi kiểu int bao giờ cũng là kiểu số nguyên có dấu kích thước ít nhất là 32 bit.
33
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Cách biểu diễn số nguyên của C++ tuân theo nguyên lý bù 2 (Two’s complement)*:
Một số nguyên 𝜆 bit được biểu diễn bằng dãy bit, đánh số từ bit 0 (bit đơn vị) tới
bit thứ 𝜆 − 1 (bit cao nhất). Trong tài liệu, các bit được viết từ phải qua trái theo
thứ tự đó. Ví dụ một giá trị trong kiểu số nguyên 8 bit (𝜆 = 8):
Số hiệu: 7 6 5 4 3 2 1 0
bit: 1 1 0 0 1 1 0 1
Nếu kiểu số nguyên này là kiểu số nguyên không dấu, dãy bit chính là biểu diễn nhị
phân của giá trị số: Lấy bit đơn vị nhân với 20 , bit số hiệu 1 nhân với 21 , …, bit số
hiệu 𝜆 − 1 nhân với 2𝜆 − 1 rồi cộng dồn kết quả lại. Ví dụ, dãy bit trong Hình 4-1
biểu diễn giá trị 205 với kiểu uint8_t:
1.20 + 0.21 + 1.22 + 1.23 + 0.24 + 0.25 + 1.26 + 1.27 = 205
Công thức tổng quát: dãy bit 𝑏𝜆−1 𝑏𝜆−2 … 𝑏1 𝑏0 biểu diễn một số nguyên không dấu
giá trị bằng:
𝜆−1
∑ 𝑏𝑖 . 2𝑖
𝑖=0
Đối với kiểu số nguyên có dấu, công thức tính hơi khác một chút: Giá trị bit cao
nhất không phải nhân với 2𝜆−1 mà nhân với −2𝜆−1. Tức là dãy bit 𝑏𝜆−1 𝑏𝜆−2 … 𝑏1 𝑏0
biểu diễn một số nguyên có dấu giá trị bằng:
𝜆−2
𝜆−1
−𝑏𝜆−1 . 2 + ∑ 𝑏𝑖 . 2𝑖
𝑖=0
Ví dụ dãy bit ở Hình 4-1 sẽ biểu diễn giá trị -51 trong kiểu int8_t†:
1.20 + 0.21 + 1.22 + 1.23 + 0.24 + 0.25 + 1.26 − 1.27 = −51
Cách tính giá trị trong kiểu không dấu và có dấu chỉ khác nhau tại bit cao nhất. Nếu
bit cao nhất (𝑏𝜆−1 ) bằng 1, giá trị không dấu được cộng vào 2𝜆−1 trong khi giá trị
* Chuẩn C++14 không bắt buộc mọi trình dịch cần biểu diễn số nguyên theo nguyên lý bù 2, tuy
nhiên mọi trình dịch hiện tại đều tuân theo nguyên lý này. Trong chuẩn C++20, nguyên lý bù 2 là
bắt buộc để biểu diễn số nguyên.
† Chú ý rằng giá trị 51 trong hệ nhị phân là 00110011, nhưng giá trị -51 trong máy tính không thể
biểu diễn kiểu -00110011, vì máy không còn thừa bất kỳ một bit nào để lưu thông tin cho dấu “-”.
Biểu diễn nhị phân trong toán học giống với biểu diễn bit trên máy tính chỉ trên các số không âm,
để biểu diễn số âm, máy tính phải dùng nguyên lý bù 2.
34
Lê Minh Hoàng
Chương 4
Biến và kiểu
có dấu bị trừ đi 2𝜆−1 ứng với bit đó. Điều này làm cho giá trị không dấu lớn hơn giá
trị có dấu tương ứng đúng 2𝜆 đơn vị (ví dụ 205 − (−51) = 256 = 28 ).
Nếu bit cao nhất bằng 0, khi đó dãy bit biểu diễn hai giá trị như nhau trong cả
kiểu có dấu và không dấu tương ứng. Giá trị nhỏ nhất mà dãy bit đó biểu diễn
được là: 00
⏟ … 0 = 0, giá trị lớn nhất mà dãy bit đó biểu diễn được là: 0 11
⏟ …1 =
𝜆 bit 0 𝜆−1 bit 1
𝜆−1
2 − 1.
Nếu bit cao nhất bằng 1, giá trị có dấu mà dãy bit đó biểu diễn là một số âm,
nhỏ hơn giá trị không dấu đúng 2𝜆 đơn vị. Đặc trưng của các số âm là có bit cao
nhất bằng 1.
Một số tính chất khác:
Dãy toàn bit 0 biểu diễn giá trị 0 trong cả kiểu số nguyên không dấu và có dấu
Dãy toàn bit 1 biểu diễn giá trị lớn nhất trong kiểu số nguyên không dấu (2𝜆 −
1), giá trị −1 trong kiểu số nguyên có dấu.
Dãy có bit cao nhất là bit 1, còn các bit khác bằng 0: 100 … 0 biểu diễn giá trị
nhỏ nhất trong kiểu số nguyên có dấu (−2𝜆 − 1).
Câu hỏi đặt ra là máy tính làm thế nào có thể tính đúng được phép toán số nguyên
nếu như nó lưu trữ hai số khác nhau bởi cùng một dãy bit. Cụ thể hơn, làm thế nào
máy tính biết được nên diễn giải dãy bit 11001101 thành 205 hay -51 để thực hiện
phép tính cho đúng?
Câu trả lời là máy không cần quan tâm, phép tính sẽ tự đúng. Đó là lợi thế của phép
biểu diễn bù 2.
Ta lấy thêm một giá trị số nữa để làm ví dụ, chẳng hạn giá trị 25, trong kiểu int8_t
hay uint8_t, giá trị này đều có biểu diễn bằng dãy bit: 00011001. Xét phép cộng hai
dãy bit*:
11001101
+
00011001
────────
11100110
Phép tính được thực hiện như các học sinh tiểu học vẫn làm, nhưng trên cơ số 2:
Cộng từ hàng đơn vị lên: Nếu kết quả là 0 hay 1 thì viết luôn xuống, nếu tổng là 2
*Theo chuẩn C++14, khi cộng hai số nguyên int8_t, máy sẽ đổi hai số nguyên đó về kiểu int rồi mới
cộng. Tuy nhiên với kiểu int, dãy bit quá dài (32 bit) nên khó nhẩm, ta giả sử máy cộng trực tiếp hai
số nguyên 8 bit cho dễ trình bày.
35
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
thì viết xuống số 0 rồi nhớ 1 sang cột bên trái, nếu tổng là 3 thì viết xuống số 1 rồi
nhớ 1 sang cột bên trái.
Dãy bit kết quả là 11100110, dãy bit này biểu diễn giá trị 230 trong kiểu uint8_t và
biểu diễn giá trị -26 trong kiểu int8_t. Nếu hai số hạng thuộc kiểu uint8_t, phép tính
này diễn giải thành 205 + 25 = 230; còn nếu hai số hạng thuộc kiểu int8_t, phép
tính này diễn giải thành −51 + 25 = −26.
Như vậy dãy bit kết quả là đúng theo quy ước biểu diễn số nguyên, máy có thể làm
hàng tỉ phép tính như vậy, chỉ xử lý trên dãy bit mà không cần biết số đang xử lý là
âm hay dương, cho đến khi thu được kết quả cuối cùng… Việc in kết quả lại là
chuyện khác, chương trình dịch khi xử lý biểu thức đã nhận diện được biểu thức
được tính theo kiểu dữ liệu nào, nó sẽ sinh mã máy để in ra số 230 hoặc số -26 tùy
theo kiểu dữ liệu tương ứng.
4.4.3. Các kiểu số thực
Các kiểu số thực trong C++ được biểu diễn trong máy theo cách tiện lợi cho bộ
đồng xử lý toán học 80x87 – một thành phần tích hợp với bộ vi xử lý chịu trách
nhiệm xử lý các phép toán số thực. Đây là thông tin cụ thể về các kiểu số thực được
G++ định nghĩa:
Tên kiểu Kích thước Số chữ số có nghĩa Phạm vi biểu diễn giá trị tuyệt đối
float 32 bit 6 1.18 × 10−38 … 3.40 × 1038
double 64 bit 15 2.23 × 10−308 … 1.80 × 1038
long double 96 bit 18 3.36 × 10−4932 … 1.19 × 104932
Kiểu số thực gồm số 0.0 và tất cả các giá trị thực thuộc phạm vi biểu diễn giá trị
tuyệt đối. Phạm vi biểu diễn giá trị tuyệt đối trong bảng trên chỉ là những con số
xấp xỉ. Các kiểu số thực có thể biểu diễn được cả số âm và số dương miễn là giá trị
tuyệt đối của nó bằng 0 hoặc nằm trong phạm vi này.
Các kiểu số thực còn hỗ trợ những giá trị đặc biệt:
Giá trị INFINITY (+∞) và -INFINITY (−∞)
Giá trị -0.0, giá trị này bằng với 0.0, nhưng nó có một vài tác dụng, chẳng
hạn:1.0/0.0 = INFINITY nhưng 1.0/−0.0 = −INFINITY.
Giá trị NAN (Not A Number): Giá trị này không bằng với bất cứ giá trị nào kể cả
chính nó, thông thường đây là kết quả của biểu thức 0.0/0.0
Những giá trị INFINITY và NAN nằm trong thư viện cmath.
4.4.4. Các kiểu ký tự chuẩn
C++ xử lý ký tự như số nguyên. Hai ký tự khác nhau tương ứng với hai số nguyên
khác nhau. Việc lưu trữ ký tự như số nguyên cho phép chúng ta so sánh ký tự, thực
hiện các phép toán +, - để tìm ký tự đứng sau hay đứng trước.
36
Lê Minh Hoàng
Chương 4
Biến và kiểu
Kiểu char là kiểu ký tự xử lý hiệu quả nhất trên máy tính, nó được ánh xạ thành
kiểu số nguyên signed char hay unsigned char tùy thuộc vào nền tảng lập trình
(trên kiến trúc vi xử lý ARM và PowerPC là unsigned char, trên kiến trúc x86, x64
là loại máy chúng ta thường dùng thì lại là signed char). Mặc dù vậy chúng ta phải
coi chúng là những kiểu khác nhau.
Nhắc lại là C++ coi các ký tự như số nguyên, ký tự đó có hình thù (glyph) như thế
nào lại do bảng mã quyết định. Bảng mã đơn giản nhất trong việc học lập trình là
bảng mã chuẩn quốc gia Mỹ để trao đổi thông tin (American Standard Codes for
Information Interchange – ASCII)
…0 …1 …2 …3 …4 …5 …6 …7 …8 …9 …A …B …C …D …E …F
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0…
NUL SOH STX ETX EOT ENQ ACK BEL BS HT LF VT FF CR SO SI
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
1…
DLE DC1 DC2 DC3 DC4 NAK SYN ETB CAN EN SUB ESC FS GS RS US
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
2… ! " # $ % & ' ( ) * + , - . /
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
3… 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
4… @ A B C D E F G H I J K L M N O
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
5… P Q R S T U V W X Y Z [ \ ] ^ _
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
6… ` a b c d e f g h i j k l m n o
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
7… p q r s t u v w x y z { | } ~ DEL
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
8 Ç ü é â ä à å ç ê ë è ï î ì Ä Å
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
9… É æ Æ ô ö ò û ù ÿ Ö Ü ø £ Ø × ƒ
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
A… á í ó ú ñ Ñ ª º ¿ ® ¬ ½ ¼ ¡ « »
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
B… ░ ▒ ▓ │ ┤ ╡ ╢ ╖ ╕ ╣ ║ ╗ ╝ ╜ ╛ ┐
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207
C… └ ┴ ┬ ├ ─ ┼ ╞ ╟ ╚ ╔ ╩ ╦ ╠ ═ ╬ ╧
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
D… ╨ ╤ ╥ ╙ ╘ ╒ ╓ ╫ ╪ ┘ ┌ █ ▄ ▌ ▐ ▀
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
E…
α ß Γ π Σ σ µ τ Φ Θ Ω δ ∞ φ ε ∩
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255
F… ≡ ± ≥ ≤ ⌠ ⌡ ÷ ≈ ° ∙ · √ ⁿ ² ■
Trong bảng mã ASCII, 32 ký tự đầu tiên (mã số từ 0 tới 31) là các ký tự điều khiển,
mặc dù một vài ký tự cũng có hình thù, việc in ra chúng chủ yếu mang vai trò điều
khiển, chẳng hạn nếu in ra ký tự số 9 (Horizontal Tab – HT) ta sẽ được một dấu
tab, ký tự số 10 tương ứng với việc xuống dòng (Line Feed – LF), ký tự số 13 tương
ứng với việc đẩy con trỏ về đầu dòng trên cửa sổ văn bản…
Các ký tự mang mã số từ 32 tới 127 là tập ký tự hay được sử dụng nhất, gần như
bắt buộc phải có trên mọi bàn phím thông thường. Mã số 32 là dấu cách, các mã số
37
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
từ 48 tới 57 tương ứng với các chữ số (0…9), các mã số từ 65 đến 90 ứng với các
chữ cái hoa tiếng Anh (A…Z), các mã số từ 97 tới 122 ứng với các chữ cái thường
tiếng Anh (a…z). Riêng mã số 127 được gán cho phím Delete trên bàn phím, dùng
để nhận phím trên cửa sổ văn bản.
Tập các ký tự mang mã số từ 128 đến 255 là tập mã ASCII mở rộng, chúng bao gồm
các ký tự kẻ khung văn bản, chữ cái tiếng Pháp, tiếng Hy Lạp,…
Trên các máy tính hiện nay, kiểu char chiếm đúng 8 bit, nó có thể biểu diễn 28 =
256 ký tự khác nhau và vì vậy nó có thể biểu diễn trọn vẹn bảng mã ASCII. Tuy
nhiên vì kiểu char được ánh xạ thành signed char, kiểu số nguyên có dấu với phạm
vi biểu diễn từ −27 đến 27 − 1 (-128…127), vì vậy các ký tự mang mã số từ 0 tới
127 tương ứng với chính giá trị số đó, còn các ký tự mang mã số 128 tới 255 tương
ứng với các giá trị số từ −128 tới −1 trong kiểu char. Điều này cần chú ý khi so
sánh ký tự.
Ví dụ: ‘A’ < ‘B’; ‘1’ < ‘2’; nhưng ‘a’ > ‘à’ dù mã của ký tự ‘a’ là 97 còn mã của chữ ‘à’
là 133 (= −123 trong kiểu char).
Ngoài ra, C++ còn hỗ trợ một vài kiểu ký tự khác, những kiểu này dùng trong phần
mềm với giao diện cần bảng mã ký tự lớn hơn:
Kiểu char16_t: Biểu diễn ký tự trong mảng mã UTF-16, nó được lưu trữ và xử
lý như kiểu uint_least16_t.
Kiểu char32_t: Biểu diễn ký tự trong mảng mã UTF-32, nó được lưu trữ và xử
lý như kiểu uint_least32_t.
Kiểu wchar_t: Kiểu để biểu diễn mọi ký tự trong bảng mã mà nền tảng lập trình
chấp nhận, kiểu này cũng được được biểu diễn như một kiểu số nguyên không
dấu.
4.4.5. Kiểu liệt kê
Kiểu liệt kê (enumeration) không phải là một kiểu dữ liệu có sẵn, nó do lập trình
viên định nghĩa nhằm tăng tính trong sáng của chương trình.
Kiểu liệt kê có thể khai báo bằng cú pháp:
enum «Tên kiểu» {«Tên các giá trị cách nhau bởi dấu phẩy»};
Ví dụ ta định nghĩa một kiểu tên là TColor chứa các giá trị màu nhìn thấy trong
quang phổ: Đỏ, Cam, Vàng, Lục, Lam, Chàm, Tím.
1 | enum TColor {Red, Orange, Yellow, Green, Blue, Indigo, Violet};
2 | TColor c = Yellow;
3 | int x = c; //x = 2
Hoàn toàn có thể quy ước màu đỏ (Red) là số 0, màu cam (Orange) là số 1, …, màu
tím (Violet) là số 6, và viết các số nguyên 0…6 thay vì các tên màu như trên. Tuy
38
Lê Minh Hoàng
Chương 4
Biến và kiểu
nhiên việc này bắt lập trình viên phải nhớ thêm một quy ước không cần thiết, tăng
khả năng nhầm lẫn khi lập trình. Các giá trị kiểu liệt kê được gán tên gợi nhớ ý
nghĩa, tránh sai sót khi soạn thảo và ngăn chặn các phép toán không được phép
trên kiểu (chẳng hạn biểu thức 𝑐 + 3 sẽ bị báo lỗi do toán tử + không được định
nghĩa trên kiểu TColor)
Về bản chất, mỗi giá trị trong kiểu liệt kê sẽ được lưu trữ như một số nguyên (tạm
gọi là mã) và hoàn toàn có thể gán cho một biến số nguyên. Nếu không có chỉ định
cụ thể, giá trị đầu tiên của kiểu liệt kê sẽ được gán mã 0, giá trị sau được gán mã
hơn mã của giá trị trước đúng 1 đơn vị, đó là lý do lệnh khởi tạo int 𝑥 = Yellow;
tương đương với int 𝑥 = 2 (Red = 0, Orange = 1, Yellow = 2,…).
Ta cũng có thể quy ước một mã khác cho một giá trị kiểu liệt kê (mã vẫn được gán
tăng dần cho các giá trị liệt kê tiếp theo nếu không được chỉ định), điều này có thể
làm cho hai giá trị liệt kê khác nhau mang mã bằng nhau. Ví dụ:
enum TColor {Red, Orange = 5, Yellow, Blue, Indigo = 6, Violet};
//Red = 0, Orange = 5, Yellow = 6, Blue = 7, Indigo = 6, Violet = 7
Hai giá trị kiểu liệt kê có thể so sánh được qua các toán tử so sánh (mục 6.6), bản
chất là so sánh hai mã tương ứng của chúng. Như ví dụ trên ta có:
Blue == Violet
Red < Yellow
4.4.6. Hiện thông tin về kiểu
Chương trình nhỏ sau đây sẽ in ra các thông tin về kiểu dữ liệu T. Chương trình sử
dụng một số hàm trong thư viện limits
1 | #include <iostream>
2 | #include <limits>
3 | using namespace std;
4 | using T = long double;
5 |
6 | int main ()
7 | {
8 | cout << boolalpha; //In ra true/false thay vì 1/0
9 | //Kích thước 1 biến tính bằng bit
10 | cout << "Size: " << sizeof(T) * 8 << " bits" << '\n';
11 | //Giá trị nhỏ nhất
12 | cout << "Minimum: " << numeric_limits<T>::min() << '\n';
13 | //Giá trị lớn nhất
14 | cout << "Maximum: " << numeric_limits<T>::max() << '\n';
15 | //Có dấu hay không?
16 | cout << "Is signed: " << numeric_limits<T>::is_signed << '\n';
17 | //Số bit biểu diễn phần không dấu
18 | cout << "Non-sign bits: " << numeric_limits<T>::digits << '\n';
19 | //Số chữ số có nghĩa, với số thực
20 | cout << "Significant digits: " << numeric_limits<T>::digits10 << '\n';
21 | //Có giá trị infty không
22 | cout << "Has infinity: " << numeric_limits<T>::has_infinity << '\n';
23 | }
Kết quả in ra:
39
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Size: 96 bits
Minimum: 3.3621e-4932
Maximum: 1.18973e+4932
Is signed: true
Non-sign bits: 64
Significant digits: 18
Has infinity: true
Bằng cách thay đổi kiểu dữ liệu T ở dòng 4, chẳng hạn thay long double bởi
intmax_t, ta sẽ có thông tin về kiểu intmax_t (nhớ nạp thư viện cstdint khi dùng
kiểu này).
40
Lê Minh Hoàng
Chương 4
Biến và kiểu
41
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Chương 5. Hằng
Hằng là một biểu thức có giá trị không đổi trong chương trình. Đôi khi người ta
phân biệt hằng (constant) với giá trị thực thể (literal). Ta sử dụng khái niệm hằng
chung cho cả hai loại. Khi cần phân biệt, ta sẽ dùng từ tên hằng (constant) và giá
trị hằng (literal).
5.1. Hằng số nguyên
Trong chương trình một giá trị số nguyên có thể viết nhiều kiểu:
Viết bình thường như trong ký pháp toán học với hệ thập phân
Viết 0b, tiếp theo là dãy bit biểu diễn giá trị đó.
Viết 0, tiếp theo là các chữ số trong hệ cơ số 8 (octa) biểu diễn giá trị đó
Viết 0x, tiếp theo là các chữ số trong hệ thập lục phân (cơ số 16 – hexa) biểu
diễn giá trị đó.
Chú ý rằng ký tự “0” trong cụm tiền tố “0b”, “0”, “0x” là chữ số 0, không phải chữ cái
O.
Ví dụ giá trị 1234, ta có thể viết theo nhiều cách khác nhau*:
1234
0b10011010010
02322
0x4d2
Các giá trị số nguyên trong biểu thức được coi là kiểu int hay long long khi tính
toán, tùy thuộc vào độ lớn của nó. Nếu muốn giá trị này khi tính toán được coi là
kiểu khác, ta có thể thêm vào các chữ cái hậu tố:
Chữ u hoặc U nếu muốn coi hằng đó thuộc kiểu không dấu (unsigned)
Chữ l hoặc L nếu muốn coi hằng đó thuộc kiểu long
Chữ ll hoặc LL nếu muốn coi hằng đó thuộc kiểu long long
Ví dụ 8ull cũng là giá trị 8 nhưng trong tính toán được coi là kiểu unsigned long
long.
*Hệ cơ số 8 dùng 8 chữ số 0, 1, 2, 3, 4, 5, 6, 7 để ký pháp các giá trị số, mỗi đơn vị ở hàng bên trái
gấp 8 lần mỗi đơn vị ở hàng bên phải liền sau. Ví dụ:
2322(8) = 2.8⏟3 + 3.8 ⏟2 + ⏟ 2.81 + 2.8
⏟0 = 1234
1024 192 16 2
Hệ cơ số 16 dùng 16 chữ số 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f. Trong đó a là chữ số có giá trị 10,
b có giá trị 11, …, f có giá trị 15. Mỗi đơn vị ở hàng bên trái gấp 16 lần mỗi đơn vị ở hàng bên phải
liền sau. Ví dụ:
4𝑑2(16) = ⏟4.162 + 13.16
⏟ 1 +⏟ 2.160 = 1234
1024 208 2
42
Lê Minh Hoàng
Chương 5
Hằng
Ngoài việc muốn chắc chắn kiểu cho giá trị hằng, việc ép kiểu cho hằng còn có tác
dụng xử lý tràn số (tràn phạm vi), điều này sẽ được trình bày rõ hơn trong chương
nói về các toán tử. Ví dụ:
cout << 2000000000 + 2000000000; //in ra -294967296
cout << 2000000000ll + 2000000000ll; //in ra 4000000000
5.2. Hằng số thực
Trong chương trình, một giá trị số thực có thể viết hai kiểu:
Viết bình thường theo ký pháp thập phân 𝑎.𝑏 trong đó 𝑎 là phần nguyên và 𝑏
là phần thập phân. Chú ý dấu phân tách phần nguyên và phần thập phân là dấu
chấm “.”, không phải dấu phẩy, và dấu này bắt buộc phải có. Nếu 𝑎 = 0 có thể
viết “.𝑏” thay vì “0.𝑏”, nếu 𝑏 = 0 có thể viết “𝑎.” thay vì “𝑎.0”. Tuy nhiên nếu 𝑎 =
𝑏 = 0, có thể viết 0. hoặc .0 nhưng không được viết mỗi dấu “.”, ví dụ: 3.1416; .5;
7. là những cách viết đúng hằng số thực.
Với 𝑋 là một số thực viết trong hệ thập phân và 𝑌 là một số nguyên, ta có thể
viết 𝑋𝑒𝑌 tương ứng với giá trị 𝑋 × 10𝑌 . Ví dụ: 1e9 là giá trị số thực bằng 1 tỉ
(1000000000), 1𝑒-9 là giá trị số thực bằng 1 phần tỉ (0.000000001).
Lý do bắt buộc phải có dấu chấm thập phân là để phân biệt hằng số nguyên với
hằng số thực. Nếu giá trị số thực (ví dụ 7.0) lại là số nguyên (7), việc viết 7 sẽ được
trình dịch hiểu là hằng số nguyên và có thể thực hiện phép tính số nguyên thay vì
phép tính số thực. Để tránh những rắc rối có thể, ta viết thêm dấu chấm “.” hoặc cả
cụm “.0” vào sau giá trị số.
Ví dụ:
cout << 7 / 2; //in ra 3, phép chia lấy phần nguyên
cout << 7. / 2.; //in ra 3.5, phép chia số thực
Các giá trị số thực trong biểu thức nếu không có chỉ định cụ thể sẽ được coi là kiểu
double. Muốn giá trị này khi tính toán được coi là kiểu khác, ta có thể thêm vào các
chữ cái hậu tố:
Chữ f hoặc F nếu muốn coi giá trị số thực là kiểu float. Ví dụ:
1.23f
Chữ l hoặc L nếu muốn coi giá trị số thực là kiểu long double. Ví dụ:
3.14159265358979324L
5.3. Hằng ký tự và hằng xâu ký tự
Cách thứ nhất để viết hằng ký tự là viết trực tiếp ký tự đó bên trong cặp dấu nháy
đơn. Ví dụ 'a', 'b', '0', '1'. Chú ý cách này không dùng để viết được chính ký tự nháy
đơn, dấu backslash “\”, hay một vài ký tự điều khiển, chẳng hạn dấu xuống dòng.
43
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Cách thứ hai để viết hằng ký tự là dùng mã điều khiển: vẫn phải có cặp ký tự nháy
đơn nhưng ở giữa là mã điều khiển* quy định trong bảng sau:
Mã điều khiển Mô tả Mã ASCII
\' Dấu nháy đơn ' 39 (0x27)
\" Dấu nháy kép " 34 (0x22)
\? Dấu hỏi ? 63 (0x3f)
\\ Dấu backslash \ 92 (0x5c)
\a Phát tiếng beep 07 (0x07)
\b Backspace (lùi con nháy lại 1 vị trí) 08 (0x08)
\f Sang trang (Khi in ra máy in) 12 (0x0c)
\n Xuống dòng 10 (0x0a)
\r Về đầu dòng 13 (0x0d)
\t Dấu tab 09 (0x09)
\v Dấu tab dọc (Khi in form) 11 (0x0b)
\nnn nnn là mã số ASCII trong hệ cơ số 8
\xnn nn là mã số ASCII trong hệ cơ số 16
Một hằng ký tự có thể có nhiều cách viết, chẳng hạn dấu chấm hỏi có thể viết là '?',
'\?', '\077', hay '\x3f', nên chọn cách viết thuận tiện nhất.
Để viết một hằng xâu ký tự, ta viết dãy ký tự nằm giữa hai dấu nháy kép, các ký tự
có thể viết trực tiếp nếu không gây ra sự nhập nhằng, hoặc viết theo mã điều khiển
ở bảng trên. Ví dụ câu lệnh:
cout << "\"How do you do\" is not a question.\nIt's formal way of saying \"Hello\".";
Sẽ in ra màn hình
"How do you do" is not a question.
It's formal way of saying "Hello".
Có một số quy tắc viết hằng ký tự và hằng xâu ký tự cho bảng mã khác như bảng
mã Unicode, hoặc phép mã hóa UTF-8, UTF-16, UTF-32, … tuy nhiên việc tìm hiểu
về cách thức bố trí ký tự trong các bảng mã này khá phức tạp, các bạn có thể tìm
hiểu trong các tài liệu khác. Trong cuốn sách này chúng ta chỉ quan tâm tới bảng
mã ASCII với kiểu ký tự char mà thôi.
5.4. Hằng logic boolean
Vì kiểu bool chỉ có hai giá trị nên để viết hằng kiểu bool, ta dùng từ khóa false
(tương ứng với giá trị chân lý Sai) và từ khóa true (tương ứng với giá trị chân lý
Đúng). Kiểu bool cũng có quy ước thứ tự: false < true.
* “Escape sequence” khó tìm từ tương đương, tôi dịch tạm thành mã điều khiển
44
Lê Minh Hoàng
Chương 5
Hằng
45
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
'a', "a",
10, 10u, 10l, 10UL, 012, 0xa,
3.14, 3.14f, 3.14L,
100u, 100u, 100., 1e2, 1e-2
Bài tập 5-2
Cách viết hằng số thực: 2f là đúng hay sai?
Bài tập 5-3
Hai giá trị hằng viết bởi 12, 012 có khác nhau không?
Các giá trị hằng, 12, 012, 12., .12e2 khác nhau gì về kiểu?
Bài tập 5-4
Những cách viết hằng sau đây có đúng không, nếu đúng, giá trị hằng bằng bao
nhiêu, thuộc kiểu gì: .314e1L, 1234f, 1.2e3.4, 123.e4
46
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
47
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Thứ tự thực hiện của biểu thức gán là từ phải qua trái: tính biểu thức vế phải trước,
thực hiện phép gán sang biến ở vế trái, rồi coi như giá trị gán là kết quả biểu thức.
Lệnh gán sau đây hoàn toàn hợp lệ trong C++:
int a, b, c;
a = b = c = 6; //a = (b = (c = 6))
Trong lệnh này, cả 𝑎, 𝑏, 𝑐 đều được gán bằng 6, nhưng thứ tự gán cho 𝑐 trước, đến
𝑏, rồi cuối cùng mới đến 𝑎.
Trong lệnh gán, «Biểu thức» vế phải phép gán phải có kiểu tương thích với biến ở
vế trái. Tương thích không có nghĩa là kiểu phải giống nhau. Có một vài quy tắc
chuyển kiểu ngầm được C++ định nghĩa, ta có thể xem ở mục 6.11 cuối bài.
6.2. Các toán tử số học trên số nguyên
Có 6 toán tử số học trên số nguyên. Những toán tử này yêu cầu các toán hạng đều
là số nguyên mới có thể thực hiện được để cho kết quả là số nguyên.
Toán tử Mô tả
+ Phép cộng
- Phép trừ
* Phép nhân
/ Phép chia lấy phần nguyên
% Phép chia lấy phần dư
- Phép lấy số đối
Nói chung các toán tử số học được viết như trong ký pháp toán học, trong đó phép
nhân ký hiệu là * (chứ không phải ×) và phép chia lấy phần dư ký hiệu là %.
Cần phân biệt phép lấy số đối (toán tử 1 ngôi*) với phép trừ (toán tử 2 ngôi†), đây
là hai toán tử khác nhau nhưng dùng chung một ký hiệu:
Ví dụ: Biểu thức
−(5 − 8) − 3
Dấu trừ đầu tiên trong biểu thức là phép lấy số đối, hai dấu trừ còn lại là phép trừ.
Thực ra C++ còn có toán tử “+” một ngôi nữa, tuy nhiên toán tử này không có ý
nghĩa lắm, thường chỉ sử dụng để trình bày code cho cân đối mà thôi. Ví dụ hai
nghiệm của phương trình |𝑥 − 3| = 𝐶 (với ẩn 𝑥 và hằng số 𝐶 ≠ 0) có thể viết như
sau cho thẳng cột:
x1 = +C + 3; //Dấu + trước chữ C chỉ là để thẳng cột với
x2 = -C + 3; //dấu lấy số đối (-C) ở hàng này
48
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
Đối với phép chia lấy phần nguyên và phép chia lấy phần dư: Giá trị của phép tính
𝑎 / 𝑏 và 𝑎 % 𝑏 quy định như sau: Đặt 𝑞 = 𝑎 / 𝑏 và 𝑟 = 𝑎 % 𝑏
Nếu 𝑏 = 0, giá trị của phép tính không xác định (đối với trình dịch G++, chương
trình sẽ chạy sinh lỗi)
Nếu 𝑏 > 0, 𝑞 là thương nguyên và 𝑟 là số dư trong phép chia 𝑎 cho 𝑏, giống như
trong toán học.
Nếu 𝑏 < 0, chuẩn C++14 chỉ đảm bảo rằng |𝑟| < |𝑏| và 𝑎 = 𝑏𝑞 + 𝑟. Tùy theo cách
cài đặt của trình dịch và cách xử lý trong tập lệnh CPU, số dư 𝑟 có thể âm hoặc
dương và theo đó giá trị 𝑞 cũng bị phụ thuộc vào trình dịch và CPU. Ví dụ vài
trường hợp cụ thể đối với G++:
a b q = a / b r = a % b
7 -2 -3 1
-7 2 -3 -1
Muốn lấy được số dư đúng theo định nghĩa toán học: 0 ≤ 𝑟 < |𝑏|, ta phải kiểm tra
nếu 𝑟 âm thì cộng thêm 𝑟 đúng 𝑏 đơn vị. Cách khác là dùng công thức:
𝑟 = (𝑎 % 𝑏 + 𝑏) % 𝑏
6.3. Các toán tử tăng/giảm
Với 𝑥 là một biến thuộc kiểu số nguyên hoặc kiểu ký tự. Biểu thức ++𝑥 hay 𝑥++
tăng giá trị của 𝑥 lên 1, biểu thức --𝑥 hay 𝑥-- giảm giá trị của 𝑥 đi 1. Tuy cùng một
công dụng đối với biến 𝑥, nhưng kết quả biểu thức có khác giữa cách viết ++𝑥 với
𝑥++, giữa cách viết --𝑥 với 𝑥--
Biểu thức Công dụng Giá trị biểu thức
++x Tăng x lên 1 Giá trị của x sau khi tăng
x++ Tăng x lên 1 Giá trị của x trước khi tăng
--x Giảm x đi 1 Giá trị của x sau khi giảm
x-- Giảm x đi 1 Giá trị của x trước khi giảm
Ví dụ:
int x = 1, y, z;
y = ++x; //x = 2, y = 2
z = x++; //x = 3, z = 2
Chú ý rằng các toán tử ++ và -- phải viết liền, không có dấu cách ở giữa. Lệnh:
y = - -x; //Có dấu cách giữa 2 dấu –
Được hiểu là lệnh gán 𝑦 = 𝑥, vì mỗi dấu “-” được diễn dịch thành phép lấy số đối.
6.4. Các toán tử trên số thực
Những toán tử số thực đặt trong biểu thức cho kết quả là số thực:
49
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Toán tử Mô tả
+ Phép cộng
- Phép trừ
* Phép nhân
/ Phép chia
- Phép lấy số đối
Các toán tử trên số thực được viết như trong ký pháp toán học. Trên số thực cũng
có toán tử một ngôi + như trên số nguyên. Ví dụ −3.0 ∗ + − 5.0 = 15.0, trong biểu
thức này tất cả các dấu + và – đều là phép toán một ngôi, chỉ phép * là phép toán
hai ngôi.
6.5. Các toán tử gán nhanh
Toán tử gán nhanh sửa đổi giá trị của một biến bằng cách thực hiện một thao tác
trên chính nó. Nếu 𝑣 là một biến và 𝑒 là một biểu thức tương thích kiểu với 𝑣:
Toán tử Cách viết Tương đương
+= v += e; v = v + e
-= v -= e; v = v - e
*= v *= e; v = v * e
/= v /= e; v = v / e
%= v %= e; v = v % e
Với ⋆ là một phép toán hai ngôi, chú ý rằng phép gán nhanh 𝑣 ⋆= 𝑒 không chắc hợp
lệ hoặc tương đương với 𝑣 = 𝑣 ⋆ 𝑒 nếu 𝑣 thuộc kiểu dữ liệu phức hợp, hoặc với
toán tử ⋆ do người lập trình định nghĩa.
6.6. Các toán tử so sánh
Hai biểu thức có thể so sánh với nhau bởi các toán tử so sánh:
Toán tử Cách viết C++ Ý nghĩa
== L == R 𝐿=𝑅
!= L != R 𝐿≠𝑅
< L < R 𝐿<𝑅
> L > R 𝐿>𝑅
<= L <= R 𝐿≤𝑅
>= L >= R 𝐿≥𝑅
Toán tử so sánh cho kết quả là true hoặc false. Kết quả là true nếu giá trị vế phải
và vế trái phù hợp với toán tử so sánh, ngược lại kết quả là false.
Ví dụ:
50
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
*Những phép toán này trong tiếng Anh là logical NOT, logical AND và logical OR. Trong thuật ngữ
toán tiếng Việt gọi là phép phủ định, phép hội logic và phép tuyển logic. Những thuật ngữ hội/tuyển
hơi xa lạ với những người không làm toán, vì vậy tôi tạm gọi là phép “và” logic và phép “hoặc” logic.
51
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
52
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
1 | #include <iostream>
2 | using namespace std;
3 |
4 | int main()
5 | {
6 | cout << boolalpha; //Yêu cầu in giá trị logic kiểu true/false thay vì 1/0
7 | int i = 3;
8 | bool b = (i < 1) && (++i == 4); //Bỏ qua phép so sánh ++i == 4
9 | cout << b << '\n'; //in ra false
10 | cout << i; //in ra 3, vì lệnh ++i ở trên không được thực hiện
11 | }
6.8. Toán tử điều kiện
Toán tử điều kiện (?) đặt trong biểu thức có cú pháp sau:
«Điều kiện» ? «A» : «B»;
«Điều kiện» là một biểu thức logic, «A» và «B» là hai biểu thức.
Nếu điều kiện đúng, giá trị của cả biểu thức điều kiện được đặt bằng «A», ngược lại
nếu điều kiện sai, giá trị của cả biểu thức điều kiện được đặt bằng «B». Chú ý rằng
chỉ một trong hai biểu thức «A» hoặc «B» được tính.
Ví dụ với 𝑥, 𝑦, 𝑧 là ba biến kiểu int, muốn đặt 𝑧 bằng giá trị lớn hơn trong hai giá trị
𝑥, 𝑦 (𝑧 = max(𝑥, 𝑦)), ta có thể viết:
z = x > y ? x : y;
6.9. Toán tử dấu phẩy
Toán tử dấu phẩy (“,”) được dùng để phân tách hai hoặc nhiều biểu thức, các biểu
thức nối tiếp bằng toán tử dấu phẩy được thực hiện lần lượt từ trái qua phải và chỉ
giá trị biểu thức cuối cùng được lấy làm kết quả.
Ví dụ với hai biến 𝑎, 𝑏 kiểu int, lệnh:
a = (b = 1, b + 5);
sẽ gán 𝑏 = 1, sau đó gán giá trị 𝑏 + 5 cho 𝑎, ta có 𝑎 = 6.
Biểu thức dùng toán tử dấu phẩy được gọi là biểu thức dấu phẩy (comma
expression)
6.10. Độ ưu tiên của các toán tử
Còn một vài toán tử khác sẽ được giới thiệu trong những chương tiếp theo, chẳng
hạn các toán tử xử lý bit, toán tử lấy địa chỉ và giải tham chiếu, …
Biểu thức trong C++ có thể chứa rất nhiều toán tử và toán hạng, ta cần biết được
độ ưu tiên của các toán tử để hiểu cách thức trình dịch diễn giải biểu thức, qua đó
đặt các cặp dấu ngoặc đơn hợp lý để biểu thức được tính đúng.
Ví dụ: Biểu thức 4 ∗ 2 + 3 ∗ 5 tương đương với cách viết (4 ∗ 2) + (3 ∗ 5), việc đặt
dấu ngoặc đơn trong trường hợp này là thừa. Nhưng biểu thức 4 ∗ 2 + 3 ∗ 5 không
tương đương với cách viết 4 ∗ (2 + 3) ∗ 5.
53
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
54
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
Đối với toán tử điều kiện 𝑎? 𝑏: 𝑐 biểu thức 𝑏 luôn được coi là được nằm trong dấu
ngoặc đơn bất kể các toán tử trong 𝑏 mức ưu tiên như thế nào so với phần còn lại
của biểu thức: 𝑎? (𝑏): 𝑐
sizeof thực ra là một hàm, nhưng nó được coi như trường hợp đặc biệt:
sizeof(𝑖𝑛𝑡) ∗ 𝑝 được diễn dịch thành (sizeof(𝑖𝑛𝑡)) ∗ 𝑝 chứ không phải
sizeof((𝑖𝑛𝑡) ∗ 𝑝)
Các toán tử dù có được định nghĩa lại (chẳng hạn đối tượng cout định nghĩa lại
toán tử <<) nhưng vẫn giữ nguyên độ ưu tiên liệt kê trong bảng trên.
Ta lấy một ví dụ để thấy rõ vai trò của độ ưu tiên các toán tử: Với 𝑥, 𝑦 là hai biến
kiểu int, ta muốn in ra giá trị lớn hơn trong hai giá trị 𝑥, 𝑦:
Biểu thức Diễn dịch thành Kết quả
std::cout << x > y ? x : y; ((std::cout << x) > y) ? x : y; Lỗi
std::cout << (x > y) ? x : y; (std::cout << (x > y)) ? x : y; Sai
std::cout << (x > y ? x : y); std::cout << ((x > y) ? x : y); Đúng
Phần còn lại của chương này đề cập tới những vấn đề hơi khó hiểu, nhưng lại rất
quan trọng trong lập trình C++ nói riêng và tất cả các ngôn ngữ lập trình định kiểu
mạnh nói chung. Trong những ngôn ngữ lập trình đó, một biểu thức đúng hoàn
toàn về mặt toán học lại có thể tính sai do không tương thích kiểu dữ liệu.
Định kiểu mạnh là đặc trưng của các ngôn ngữ lập trình hiệu suất cao. Muốn tận
dụng được hiệu suất của các ngôn ngữ lập trình đó, bắt buộc lập trình viên phải
hiểu rõ cơ chế xử lý biểu thức.
6.11. Cơ chế ngầm chuyển đổi kiểu
Ngầm chuyển đổi kiểu (implicit type conversion) là tình trạng một giá trị biểu thức
được tự chuyển đổi sang kiểu khác nhằm thích nghi với việc tính toán hay gán giá
trị cho biến. Việc này do chương trình dịch sinh mã tự thực hiện, lập trình viên
không cần can thiệp.
6.11.1. Ngầm chuyển đổi kiểu trong phép gán và khởi tạo biến
Xét phép gán:
𝑥 = 𝐸;
Trong đó 𝑥 là một biến và 𝐸 là một biểu thức.
Ngầm chuyển đổi kiểu khi khởi tạo/gán biến bool
Khi khởi tạo hoặc gán giá trị một biến bool 𝑥 bằng biểu thức 𝐸. Biểu thức 𝐸 có thể
thuộc kiểu bool, số nguyên, số thực, giá trị trong kiểu liệt kê, con trỏ. Nếu 𝐸 bằng
false (kiểu bool), hoặc 0 (số nguyên, số thực hoặc mã thứ tự trong kiểu liệt kê),
hoặc nullptr (con trỏ), biến 𝑥 sẽ nhận giá trị false. Nếu không 𝑥 sẽ nhận giá trị true.
55
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
bool x;
x = 5; //x = true
x = 0; //x = false
x = 0.0; //x = false
x = 'a'; //x = true, C++ xử lý ký tự như số nguyên, 'a' = 97
Ngầm chuyển đổi kiểu khi khởi tạo/gán biến số nguyên
Khi khởi tạo hoặc gán giá trị cho một biến nguyên 𝑥 bằng biểu thức 𝐸:
Nếu 𝐸 có kiểu bool, giá trị false được ngầm chuyển thành 0 và giá trị true được
ngầm chuyển thành 1.
Nếu 𝐸 có kiểu số thực, phần thập phân sẽ được cắt bỏ, phần nguyên được gán
cho 𝑥, nếu giá trị phần nguyên nằm ngoài phạm vi biểu diễn của kiểu biến 𝑥,
kết quả không xác định (undefined)
Nếu 𝐸 có kiểu số nguyên (hoặc kiểu được coi như số nguyên như ký tự, liệt kê):
Nếu giá trị của 𝐸 nằm trong phạm vi biểu diễn của kiểu biến 𝑥. Giá trị này
sẽ được gán cho 𝑥.
Nếu giá trị của 𝐸 nằm ngoài phạm vi biểu diễn của kiểu biến 𝑥, 𝑥 sẽ được
gán giá trị 𝐸 ′ trong kiểu sao cho 𝐸 ′ đồng dư với 𝐸 theo mô đun 2𝜆 . Ở đây
𝜆 là kích thước tính theo bit của kiểu biến 𝑥 (𝜆 ∈ {8,16,32,64}).
1 | int16_t x; //int16_t: -215 … 215-1 = -32768 … 32767
2 | x = false; //Gán int16_t = bool: x = 0
3 | x = true; // Gán int16_t = bool: x = 1
4 | x = -2.5; // Gán int16_t = double: x = -2
5 | x = 40000.9; //Gán int16_t = double: không xác định
6 | x = 3; // Gán int16_t = int: x = 3
7 | x = 1234567; //Gán int16_t = int: x = -10617 (216 = 65536)
Đôi điều phải nói thêm về phép gán 𝑥 = 𝐸 trong đó 𝐸 là một biểu thức nguyên có
giá trị nằm ngoài kiểu của 𝑥. Dĩ nhiên khi đó, kiểu của 𝐸 có số bit không ít hơn 𝜆 là
kích thước kiểu của 𝑥. Phép gán 𝑥 = 𝐸 sẽ copy chính xác 𝜆 bit của 𝐸 sang 𝑥 bắt đầu
từ bit thấp nhất (bit đơn vị), những bit cao hơn (nếu có) sẽ bị bỏ qua và không
được copy. Tức là nếu sau phép gán đó, giá trị của 𝑥 = 𝐸 ′ thì 𝐸 − 𝐸 ′ sẽ có dãy bit
tận cùng bởi 𝜆 bit 0, nói cách khác 𝐸 − 𝐸 ′ chia hết cho 2𝜆 : 𝐸 ≡ 𝐸′(mod 2𝜆 ) bất kể
𝑥 và 𝐸 là kiểu số nguyên không dấu hay có dấu.
(Lưu ý rằng miền giá trị của kiểu biến 𝑥 gồm 2𝜆 số nguyên liên tiếp, vì vậy chỉ có
duy nhất một giá trị đồng dư với 𝐸 theo mô đun 2𝜆 mà thôi. Điều đó đảm bảo kết
quả phép gán xác định duy nhất)
Ví dụ trong lệnh gán ở dòng 7:
1234567 = 00000000 00010010 11010110 10000111
-10617 = 11010110 10000111
56
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
Cơ chế này chỉ đúng khi các số nguyên trong máy được biểu diễn tuân theo nguyên
lý bù 2 (xem mục 4.4.2). Trong chuẩn C++14, nguyên lý bù 2 là không bắt buộc, vì
vậy lệnh khởi tạo/gán tràn phạm vi cho một biến 𝑥 kiểu số nguyên có dấu cũng bị
coi là không xác định.
Nguyên lý bù 2 hiện đang áp dụng cho mọi trình dịch C++, nó cũng sẽ sớm được
đưa vào chuẩn C++20. Vì vậy riêng trường hợp này ta có thể thoải mái sử dụng các
kiến thức trên mà không phải lo ngại về hành vi không xác định (undefined
behavior).
Việc khởi tạo hoặc gán giá trị tràn phạm vi kiểu số nguyên khá phức tạp về bản
chất. Những lập trình viên sử dụng kiểu gán này hoặc là rất kinh nghiệm, hoặc là
rất ngớ ngẩn. Lời khuyên cho các bạn mới học lập trình là: Hạn chế sử dụng phép
khởi tạo hoặc gán tràn phạm vi đối với biến kiểu số nguyên.
Ngầm chuyển đổi kiểu khi khởi tạo/gán biến số thực
Khi khởi tạo hoặc gán giá trị cho một biến số thực 𝑥 bằng biểu thức 𝐸:
Nếu 𝐸 có kiểu số thực, giá trị được gán tự nhiên sang 𝑥
Nếu 𝐸 là kiểu số nguyên hoặc kiểu có thể ngầm chuyển sang kiểu số nguyên (ký
tự, liệt kê, con trỏ, bool), giá trị được gán sang 𝑥 với phần thập phân bằng .0
Khi khởi tạo hoặc gán giá trị biểu thức cho biến số thực, một vài chữ số có thể bị
mất, chữ số cuối cùng có thể được làm tròn tùy thuộc vào số chữ số có nghĩa của
kiểu biến số thực. Việc có làm tròn hay không là tùy thuộc vào máy tính và chương
trình dịch (implementation-defined). Tức là kết quả có thể có sai số, nhưng sai số
này do làm tròn lên hay làm tròn xuống tùy thuộc các máy khác nhau, trình dịch
khác nhau.
Phép gán và phép khởi tạo biến chỉ giống nhau trên các kiểu dữ liệu cơ sở, chúng
có hiệu ứng khác nhau trên các kiểu phức hợp, ta sẽ đề cập đến vấn đề này khi nói
về các kiểu dữ liệu đó.
6.11.2. Ngầm chuyển đổi kiểu trong biểu thức số học
Khi tính toán một biểu thức số học, trình dịch phân tích biểu thức lớn ra thành các
biểu thức nhỏ, mỗi biểu thức nhỏ chỉ gồm một toán tử số học. Ví dụ biểu thức:
(8 + 3) ∗ (9 − 4)
Sẽ được tính qua các biểu thức nhỏ sau:
8 + 3 = 11
9−4=5
11 ∗ 5 = 55
Do đặc thù của trình dịch, chỉ một vài kiểu dữ liệu số học có thể thực hiện việc tính
toán. Những kiểu dữ liệu khác phải ngầm chuyển thành một trong những kiểu này
57
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
mới có thể tính toán được bằng các phép tính số học. Danh sách các kiểu “tính được”
này xếp theo thứ tự từ “nhỏ” đến “lớn” theo danh sách sau:
int
unsigned
long
unsigned long
long long
unsigned long long
float
double
long double
Tính “nhỏ” và “lớn” của kiểu, ta đặt trong dấu nháy kép, là do quy ước của chuẩn.
Không có nghĩa là kiểu “lớn” hơn thì chiếm nhiều bit hơn hay có phạm vi biểu diễn
rộng hơn.
Các toán hạng trong biểu thức sẽ được ngầm chuyển đổi sang các kiểu “tính được”
ở trên bằng cách áp dụng lần lượt các hai quy tắc sau đây:
Quy tắc 1: Toán hạng sẽ được ngầm chuyển sang kiểu “tính được” nhỏ nhất có miền
giá trị chứa trọn vẹn miền giá trị của kiểu toán hạng. Trên các máy tính chúng ta
dùng lập trình, kiểu int là int32_t, tức là các toán hạng kiểu int8_t, uint8_t, int16_t,
uint16_t, kể cả bool và char (theo quy tắc ngầm chuyển kiểu khi khởi tạo biến, mục
6.11.1) sẽ được chuyển hết thành int trước khi thực hiện toán tử số học. Các kiểu
dữ liệu số khác không đổi do chúng đều là kiểu “tính được”.
Quy tắc 2: Nếu toán tử yêu cầu nhiều toán hạng, tất cả các toán hạng sẽ được
chuyển thành kiểu “lớn” nhất trong các kiểu toán hạng. Giá trị biểu thức được tính
trong chính kiểu này.
Ta xét một vài đoạn chương trình sau để tìm hiểu bản chất của phép ngầm chuyển
đổi kiểu khi tính toán biểu thức:
1 | unsigned short a = 0;
2 | unsigned int b = 0;
3 | long long x;
4 | x = a - 1; //x = -1
5 | x = b - 1; //x = 4294967295
Ta nhận ra một kết quả “kỳ quặc”: Rõ ràng kiểu của 𝑏 (unsigned int) bao trùm cả
kiểu của 𝑎 (unsigned short), nhưng phép tính 𝑎 − 1 đúng còn phép tính 𝑏 − 1 lại
không đúng với kết quả toán học.
Số 1 trong biểu thức được coi là kiểu int theo quy tắc viết hằng số (mục 5.1). Vậy
nên:
Biểu thức 𝑎 − 1 được diễn giải thành
unsigned short - int = int - int = int
58
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
59
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Ở dòng 3, 𝑎 bị đổi kiểu thành float và biểu thức được tính theo kiểu float (sai số
nhiều nhất). Ở dòng 4, 𝑎 bị đổi kiểu thành double và biểu thức được tính theo kiểu
double (sai số ít hơn). Ở dòng 5, 𝑎 bị đổi kiểu thành long double và biểu thức cũng
được tính theo kiểu long double (trong trường hợp này không có sai số).
6.12. Toán tử ép kiểu
C++ cho phép lập trình viên ép kiểu một số toán hạng để can thiệp vào quá trình
ngầm chuyển đổi kiểu khi sinh mã tính toán biểu thức. Việc ép kiểu cho toán hạng
𝑎 tương tự như việc lấy giá trị của 𝑎 gán cho biến 𝑎′ thuộc một kiểu khác rồi tính
toán biểu thức theo toán hạng 𝑎′ thay vì 𝑎. Có hai cú pháp đơn giản để ép kiểu:
(«Tên kiểu»)«Biểu thức» hoặc «Tên kiểu»(«Biểu thức»)
Cả hai cách viết có công dụng tương đương: Đầu tiên «biểu thức» được tính bình
thường áp dụng các luật tính toán: độ ưu tiên các toán tử (mục 6.10), cơ chế ngầm
chuyển đổi kiểu trong tính toán biểu thức (mục 6.11.2), … để được một giá trị. Giá
trị này được chuyển sang kiểu chỉ ra trong «Tên kiểu», quy tắc chuyển được nêu
ra trong cơ chế ngầm chuyển đổi kiểu trong phép gán/khởi tạo biến (mục 6.11.1).
Giá trị sau khi đã chuyển kiểu sẽ được dùng để tính toán tiếp…
Cách viết thứ nhất thường được dùng khi «Biểu thức» là một hằng hay một biến.
Cách viết thứ hai thường được dùng khi «Tên kiểu» không có dấu cách. Nếu biểu
thức phức tạp và tên kiểu cũng có dấu cách, ta phải dùng các cặp dấu ngoặc đơn
bọc lấy cả phần «Tên kiểu» và phần «Biểu thức»:
(«Tên kiểu»)(«Biểu thức»)
Ví dụ: Tính trung bình cộng và tích của hai số nguyên 𝑎, 𝑏:
1 | int a = 999999;
2 | int b = 1000000;
3 | double c = double(a + b) / 2; //c = 999999.5
4 | long long d = (long long)a * b; //d = 999999000000
Dòng 3, máy tính 𝑎 + 𝑏 = 1999999 kiểu int, ép kiểu số nguyên int này thành
double rồi chia 2, đây là phép chia số thực (hằng số 2 kiểu int được ngầm chuyển
thành double), cho kết quả là 999999.5 kiểu double, kết quả này được gán cho 𝑐.
Nếu viết:
double c = (a + b) / 2;
Phép chia trong biểu thức này là phép chia nguyên, cho kết quả 999999 kiểu int,
khi gán cho 𝑐, cơ chế ngầm chuyển đổi kiểu cho 𝑐 = 999999.0
Những cách viết sau cũng cho giá trị trung bình cộng đúng, các bạn có thể tự phân
tích theo luật:
60
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
double c = (double(a) + b) / 2;
double c = (a + (double)b) / 2;
double c = (a + b) / double(2);
double c = (a + b) / 2.0
Dòng 4, 𝑎 được ép kiểu thành long long trước. Theo quy tắc ngầm chuyển đổi kiểu
khi thực hiện phép nhân, 𝑏 sau đó cũng được ngầm chuyển thành kiểu long long
và tích 𝑎 ∗ 𝑏 được tính theo kiểu long long, ta có phép tính đúng. Thu được 𝑑 =
999999000000
Nếu viết:
d = a * b;
Phép tính 𝑎 ∗ 𝑏 có cả hai toán hạng kiểu int, phép nhân sẽ được tính theo kiểu int
và được kết quả tràn khỏi phạm vi biểu diễn kiểu int (−231 … 231 − 1). Giá trị tính
sai (−728379968) sau đó được gán cho 𝑑… Lỗi này ta đã phân tích trong mục
trước.
Chú ý rằng nếu viết:
d = (long long)(a * b)
Ta vẫn thu được một phép tính sai do lỗi tràn số với sai lầm chưa hề được sửa
chữa. Lý do là ta bị tràn số ngay ở phép nhân, kết quả đã sai thì ép kiểu thành long
long vẫn không có gì thay đổi.
Cách ép kiểu này kế thừa từ ngôn ngữ C, bị coi là nguy hiểm với các kiểu dữ liệu có
cấu trúc, hay để lại rác trong bộ nhớ và kéo theo nhiều hành vi không xác định. C++
đã bổ sung thêm nhiều toán tử ép kiểu khác an toàn và tinh vi hơn, ta sẽ trình bày
chúng ở những chương sau. Kinh nghiệm là chỉ nên sử dụng toán tử ép kiểu nói
trên đối với các kiểu dữ liệu cơ sở mà chúng ta đã điểm qua.
6.13. So sánh hai biểu thức khác kiểu
Không chỉ các toán tử số học, biểu thức với toán tử so sánh, toán tử xử lý bit, cũng
tuân theo luật ngầm chuyển đổi kiểu, cho phép ép kiểu.
Có hai điều nên tránh khi viết biểu thức số nguyên, trừ khi bạn biết chắc chắn mình
đang làm gì.
Không dùng lẫn kiểu số nguyên có dấu và không dấu trong cùng một biểu thức
Không dùng kiểu số nguyên không dấu với phép tính trừ
Hai tiêu chuẩn trên có thể đạt được bằng cách ép kiểu. Vi phạm hai tiêu chuẩn này
dễ dẫn tới những kết quả sai và rất khó kiểm soát. Ví dụ:
1 | int a = -2;
2 | unsigned b = 3;
3 | bool c = a > b; //c = true
4 | bool d = b - 10 < 0; //d = false
61
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
Ở dòng 3, 𝑎 được ép kiểu thành unsigned trước khi so sánh với 𝑏, phép so sánh
−2 > 3 trở thành 4294967294 > 3 và cho giá trị đúng (true).
Ở dòng 4, số 10 (kiểu int) được ép kiểu thành unsigned, phép tính 𝑏 − 10 tính theo
kiểu unsigned không bao giờ cho số âm được, kết quả phép so sánh là sai (false).
Nếu bạn không muốn máy cho rằng −2 > 3 hay 3 − 10 ≮ 0, hãy ép kiểu cho toán
hạng trước khi so sánh:
1 | int a = -2;
2 | unsigned b = 3;
3 | bool c = a > int(b); //c = false
4 | bool d = int(b) - 10 < 0; //d = true
6.14. Hành vi không xác định và hành vi không chuẩn
Hành vi không xác định (undefined behaviors) nghĩa là chương trình thực hiện một
hành động trong một tình trạng ngẫu nhiên nào đó mà vô tình nó làm cho hành
động trở nên đúng. Khi chạy chương trình ở máy khác, hoặc ở thời điểm khác, hành
động đó có thể cho kết quả khác.
Một hành vi không xác định hay gặp nhất là lỗi đọc giá trị biến chưa khởi tạo cũng
như chưa gán giá trị. Ví dụ:
int i;
i = i + 1;
Rất nhiều người cho rằng khi khai báo biến 𝑖, nó sẽ nhận giá trị 0 và vì thế lệnh gán
tiếp theo sẽ cho giá trị 𝑖 bằng 1. Thực ra không phải như vậy, khi khai báo biến 𝑖,
biến đó được đặt vào một vùng nhớ, vùng nhớ này có thể chứa “rác” do chương
trình trước để lại hoặc rác do chính quá trình cấp phát và giải phóng bộ nhớ tạo ra.
Lệnh gán 𝑖 = 𝑖 + 1 sẽ lấy giá trị của biến 𝑖 (đang là rác) cộng thêm 1 rồi gán lại cho
biến 𝑖, kết quả là biến 𝑖 sau lệnh gán này mang kết quả không xác định.
Còn rất nhiều kiểu hành vi không xác định khác: Truy cập phần tử ngoài mảng, giải
tham chiếu con trỏ nullptr, thay đổi giá trị biến nhiều lần ngay trong cùng một biểu
thức…
Hành vi không chuẩn, hay hành vi phụ thuộc đặc thù cài đặt (implementation-
defined behaviors) lỗi nhẹ hơn, trên thực tế chương trình vẫn chạy ổn định với một
trình dịch, một nền tảng lập trình xác định trước, nhưng vì sử dụng các lệnh đặc
thù của môi trường lập trình, khi mang sang trình dịch khác hoặc một máy tính với
kiến trúc khác, chương trình có thể không dịch được hoặc chạy sai. Ví dụ:
Lệnh tiền xử lý:
#include <bits/stdc++.h>
Lệnh này dịch tốt trên các phiên bản G++, nhưng các chương trình dịch khác không
hiểu và báo lỗi.
62
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
Một ví dụ khác là hàm __gcd(. , . ) để tính ước số chung lớn nhất của hai số. Nói
chung các hàm bắt đầu bằng hai dấu gạch nối dưới là để chương trình dịch dùng
vào mục đích riêng, không phải cho người lập trình sử dụng vì nó không phải chuẩn
C++, trong các thế hệ sau của chương trình dịch, hàm này có thể không còn hoặc
đổi tên.
Chúng ta không được lập trình những hành vi không xác định và nên hạn chế hành
vi không chuẩn.
63
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
64
Lê Minh Hoàng
Chương 6
Toán tử và biểu thức
65
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
66
Lê Minh Hoàng
Chương 7
Nhập/xuất dữ liệu cơ bản
Ta cũng có thể viết gộp các toán tử << trong các lệnh trên và được kết quả tương
tự:
1 | int x = 4;
2 | std::cout << "abc" << 123 << x;
Xét lệnh std::cout << 𝐸, trong đó 𝐸 là một biểu thức, toán tử << ngoài tác dụng ghi
giá trị biểu thức 𝐸 ra thiết bị xuất chuẩn, nó còn trả về giá trị của cả biểu thức
(std::cout << 𝐸), giá trị của cả biểu thức này chính là đối tượng std::cout sau khi
giá trị biểu thức 𝐸 đã được ghi ra. Điều này giải thích tính logic của lệnh ở dòng 2
nếu viết thêm các cặp dấu ngoặc đơn biểu thị quá trình xử lý biểu thức:
((std::cout << "abc") << 123) << x;
Khi ký tự '\n' được ghi ra luồng xuất chuẩn, thiết bị xuất chuẩn tương ứng sẽ xuống
dòng để những lệnh in ra tiếp theo được bắt đầu trên một dòng mới.
1 | int x = 4; abc
2 | std::cout << "abc\n"; 123
3 | std::cout << 123 << '\n'; 4
4 | std::cout << x << '\n';
Thay vì in ra ký tự '\n', ta có thể in ra đối tượng std::endl để xuống dòng, chú ý là
đối tượng này không phải là ký tự và cách thức hoạt động có khác so với ký tự '\n'.
Ta sẽ trình bày rõ hơn ở cuối chương.
7.3. Luồng nhập chuẩn
Luồng nhập chuẩn có tên là std::cin, đây là luồng chỉ cho phép đọc và mặc định nó
đại diện cho thiết bị bàn phím. Đối tượng std::cin được định nghĩa lại toán tử >>
để đọc dữ liệu từ thiết bị nhập chuẩn ra một biến đứng sau toán tử (biến thuộc
kiểu cơ sở hoặc kiểu xâu ký tự). Ví dụ một chương trình cho nhập vào hai số nguyên
và in ra tổng hai số đó:
1 | #include <iostream> Cho 2 so nguyen: 2 3
2 | Tong hai so = 5
3 | int main()
4 | {
5 | int x, y;
6 | std::cout << "Cho 2 so nguyen: ";
7 | std::cin >> x;
8 | std::cin >> y;
9 | std::cout << "Tong hai so = " << x + y;
10 | }
Khi chạy chương trình,
Lệnh ở dòng 6 in ra thông báo “Cho 2 so nguyen: ”.
Hai lệnh ở dòng 7 và dòng 8 sẽ đọc giá trị hai biến 𝑥, 𝑦 từ thiết bị nhập chuẩn (chờ
người dùng gõ vào giá trị 2 số nguyên từ bàn phím, hai giá trị này có thể gõ cách
67
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
nhau bởi một hoặc vài dấu cách, hoặc dấu xuống dòng, nhưng cần phải bấm Enter
khi kết thúc nhập).
Lệnh ở dòng 9 in ra “Tong hai so = ”, tiếp theo là giá trị biểu thức 𝑥 + 𝑦 là tổng hai
số được nhập vào.
Lệnh std::cin >> 𝑥 còn là một biểu thức có giá trị đúng bằng đối tượng std::cin
khi đã đọc xong giá trị cho biến 𝑥. Vì vậy hai lệnh ở dòng 7 và dòng 8 có thể viết
gộp thành
std::cin >> x >> y;
vì lệnh này được diễn giải:
(std::cin >> x) >> y;
7.4. Các luồng khác
Luồng std::cerr và std::clog cùng loại với std::cout, đều là để tương tác với các thiết
bị ra (output devices), chúng thuộc các kiểu luồng xuất (output stream) – chỉ cho
phép ghi ra luồng. Trong khi đó std::cin thuộc kiểu luồng nhập (input stream) – chỉ
cho phép đọc từ luồng.
Có một số luồng khác như std::wcout, std::wcerr, std::wclog, std::wcin dùng để xử
lý các ký tự thuộc bảng mã lớn hơn như Unicode, các bạn có thể tham khảo trong
các tài liệu khác.
Trong lập trình, luồng cerr thường được dùng để ghi ra các thông báo lỗi, còn luồng
clog thường để ghi biên bản trong quá trình chạy nhằm mục đích gỡ rối.
Khi nhiều chương trình chạy đồng thời, luồng xuất của chương trình này có thể trở
thành luồng nhập của chương trình khác thông qua cơ chế định hướng lại
(redirect) thiết bị nhập/xuất. Một trong những ví dụ là trình dịch G++ và
Code∷Blocks: Khi Code∷Blocks dịch chương trình, nó gọi G++ để dịch và lấy về các
thông báo lỗi, kết quả dịch thông qua các luồng xuất của G++, từ đó hiển thị thông
báo lỗi, dòng lỗi, hay các cảnh báo lên giao diện của chính Code∷Blocks.
7.5. Một số chi tiết kỹ thuật
7.5.1. Vùng đệm đọc/ghi
Các luồng, trong quá trình tương tác với thiết bị, có thể sử dụng một vùng bộ nhớ
gọi là đệm (buffer). Ví dụ mỗi lượt máy sẽ đọc một lượng lớn dữ liệu từ thiết bị
nhập vào vùng đệm, các lệnh nhập lấy dữ liệu từ vùng đệm, cho tới khi hết dữ liệu
trong vùng đệm thì máy lại đọc một lượng lớn dữ liệu tiếp theo… Cách làm ngược
lại với các thiết bị xuất. Các lệnh xuất ghi dữ liệu ra vùng đệm, cho tới khi vùng
đệm đầy thì thực hiện xả đệm: toàn bộ vùng đệm được đẩy ra thiết bị xuất và vùng
đệm lại được làm rỗng để sẵn sàng đón nhận dữ liệu kế tiếp…
68
Lê Minh Hoàng
Chương 7
Nhập/xuất dữ liệu cơ bản
Sử dụng vùng đệm cho phép giảm số lần khởi động/tắt mô tơ ổ đĩa, giảm số lần
thiết lập kết nối mạng, tăng độ bền thiết bị và giúp quá trình đọc/ghi nhanh hơn
đáng kể.
Bây giờ ta sẽ trình bày sự khác nhau giữa ký tự ‘\n’ và đối tượng std::endl khi ghi
ra một luồng xuất:
Lệnh std::cout << '\n' ghi ký tự xuống dòng ra vùng đệm bình thường như
mọi ký tự khác. Nếu vùng đệm đầy mới thực hiện phép xả đệm.
Lệnh std::cout << std::endl cũng ghi ký tự xuống dòng ra vùng đệm, nhưng
ngay sau đó nó thực hiện lệnh xả đệm (std::cout.flush()). Kết quả in ra trên
thiết bị xuất chuẩn (màn hình) ngay tức thì.
Vậy khi nào dùng '\n' và khi nào dùng std::endl?
Ta dùng std::endl khi cần thấy kết quả ngay (chẳng hạn khi ghi những thông tin lỗi
ra cerr hoặc thông tin để gỡ rối ra clog). Lưu ý rằng std::endl làm mất hiệu ứng của
bộ nhớ đệm, chậm hơn đáng kể so với '\n' nếu phải in ra nhiều dòng.
Lời khuyên ở đây là dùng std::endl nếu hiểu công dụng của nó, còn nếu không, ta
dùng '\n' và khi cần xả đệm linh hoạt có thể viết thêm lệnh std::cout.flush() (hoặc
std::cout << std::flush);
Việc xả đệm thường được dùng nếu chương trình tương tác với người. Các thông
báo phải được in ra ngay lập tức để người dùng quyết định hành động, chẳng hạn
nếu ta chạy chương trình ở chế độ gỡ rối, từng dòng một, nếu chạy qua một dòng
có lệnh xuất nhưng dữ liệu không in ra màn hình do vẫn nằm trong vùng đệm thì
rất khó gỡ rối.
Khi chương trình kết thúc, việc xả đệm được thực hiện tự động trước khi thiết bị
xuất đóng lại. Chính vì vậy, chương trình không bao giờ nên sử dụng std::endl mà
nên dùng '\n' nếu chạy trong các hệ thống kiểm định tự động bằng test. Ngoại trừ
một vài trường hợp hãn hữu, chẳng hạn hai chương trình thực hiện một công việc
hay một trò chơi tương tác: luồng xuất của mỗi chương trình được định hướng vào
luồng nhập của chương trình còn lại. Khi một chương trình gửi thông điệp cho
chương trình còn lại và chờ phản hồi, việc xả đệm khiến thông điệp chính thức
được gửi đi và do đó, chương trình còn lại mới có thể xử lý và phản hồi.
7.5.2. Cơ chế đồng bộ luồng C và C++
Ngôn ngữ C++ được phát triển từ ngôn ngữ C, các chuẩn C++ vẫn cố gắng giữ được
tính tương thích nhất định với các chương trình cũ trên ngôn ngữ C. Một trong
những sự thỏa hiệp là C++ vẫn giữ cơ chế nhập xuất của C (scanf/printf) nhưng cố
gắng đồng bộ hai cơ chế nhập xuất. Lệnh ghi ra thiết bị xuất chuẩn của C là
printf(… ), còn lệnh ghi ra thiết bị xuất chuẩn của C++ là std::cout << ⋯. Mặc dù
69
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
chúng đều tương tác với thiết bị xuất chuẩn nhưng chúng lại là hai luồng hoạt động
tương tranh. Có nghĩa là nếu chương trình sử dụng lẫn lộn cả hai kiểu lệnh trên,
hai luồng xuất chuẩn của C và C++ có thể tranh nhau sử dụng thiết bị, dẫn đến tình
trạng lệnh xuất trước nhưng kết quả có thể hiển thị sau.
Để tránh tình trạng tương tranh, C++ đặt mặc định cơ chế đồng bộ giữa luồng nhập
xuất của nó và luồng nhập xuất cũ của C. Chẳng hạn, cho luồng C++ xả trước mỗi
lệnh ghi ra luồng C và ngược lại, cho luồng C xả trước mỗi lệnh ghi ra luồng C++.
Việc này lại làm cơ chế nhập xuất chậm đi đáng kể.
Để tăng tốc độ nhập xuất, khi chắc chắn rằng chương trình không sử dụng lẫn lộn
các luồng nhập xuất của C và C++, ta có thể tắt cơ chế đồng bộ này bằng lệnh:
std::ios_base::sync_with_stdio(false);
Tham số của lệnh là false nếu muốn tắt cơ chế đồng bộ luồng nhập xuất của C và
C++, nếu muốn bật lại chế độ đồng bộ, thực hiện lệnh trên với tham số true.
7.5.3. Cơ chế giằng luồng nhập/xuất
Ta xét hai lệnh sau:
std::cout << "Cho so nguyen duong x: ";
std::cin >> x;
Trong mục 7.5.1, ta đã trình bày về hiệu ứng của vùng đệm nhập/xuất. Hãy hình
dung nếu dòng thông báo “Cho so nguyen duong x: ” vẫn còn trong vùng đệm và
chưa được in ra màn hình, khi mà lệnh ở dòng sau đã chờ nhập 𝑥: người dùng sẽ
thấy một màn hình trống, không biết phải làm gì.
C++ không để xảy ra tình trạng này bằng cơ chế giằng luồng std::cin với luồng
std::cout: Mỗi khi có lệnh nhập từ std::cin, luồng std::cout sẽ xả trước. Điều này lại
khiến cho quá trình nhập xuất chậm đi đáng kể.
Khi chương trình chạy trong các hệ thống kiểm định tự động bằng test, có thể
không cho std::cin được giằng với bất kỳ luồng xuất nào nữa bởi lệnh:
std::cin.tie(nullptr);
Còn nếu muốn cho std::cin tái thiết việc giằng với std::cout, ta có thể viết lệnh
std::cin.tie(&std::cout).
Để hiểu rõ hơn về hiệu ứng bộ nhớ đệm cũng như cơ chế giằng luồng. Ta xét
chương trình nhập vào một số nguyên 𝑥 và cho biết 𝑥 là số chẵn hay số lẻ.
70
Lê Minh Hoàng
Chương 7
Nhập/xuất dữ liệu cơ bản
1 | #include <iostream>
2 | using namespace std;
3 |
4 | int main()
5 | {
6 | ios_base::sync_with_stdio(false);
7 | cin.tie(nullptr);
8 | int x;
9 | cout << "Cho so nguyen x: ";
10 | cin >> x;
11 | cout << x << " la so " << (x % 2 == 0 ? "chan" : "le");
12 | }
Chạy chương trình, ta có thể thấy trên màn hình không hiện ra thông báo gì, nhập
vào số 8, chương trình chạy và kết thúc với giao diện như sau:
8
Cho so nguyen x: 8 la so chan
Điều này được lý giải là tất cả các dữ liệu in ra thiết bị xuất chuẩn vẫn còn nằm
trong vùng đệm, chưa đưa ra thiết bị kể cả khi người dùng đã nhập vào giá trị 𝑥.
Đến khi chương trình kết thúc, vùng đệm mới được xả và in ra hết.
Có hai cách khắc phục mà các bạn có thể thử nghiệm:
Bỏ dòng 7 hoặc biến nó thành chú thích: //cin.tie(nullptr);
Cách khác là thay vào dòng 9 thành: cout << "Cho so nguyen x: " << flush;
Lời khuyên là nên dùng cách thứ hai: xả đệm một cách chủ động khi cần thiết.
Trong bài thi của các kỳ thi lập trình, hai lệnh ở dòng 6 và dòng 7:
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
thường là những lệnh được thực hiện đầu tiên nhằm tăng tốc độ nhập xuất, điều
này quan trọng khi mà giới hạn thời gian cho mỗi test chấm được tính chi li đến
từng milli giây. Tuy nhiên trong các chương trình ví dụ, đưa hai dòng này vào trong
mọi chương trình sẽ làm dài dòng thêm và vướng mắt không cần thiết. Các chương
trình trong sách sẽ không có hai lệnh này, các bạn có thể thêm vào nếu thực tế cần
thiết.
71
Ngôn ngữ lập trình C++
Phần II
CÁC THÀNH TỐ CƠ BẢN CỦA C++
72
Lê Minh Hoàng
Phần III.
Lập trình cấu trúc
Lập trình cấu trúc (structured programming) là một mô hình lập trình nhằm viết
ra các chương trình rõ ràng, chất lượng, giảm thời gian phát triển cũng như bảo
trì chương trình.
Trong khi các ngôn ngữ lập trình bậc thấp chỉ cho phép cấu trúc lệnh tuần tự và
lệnh nhảy, các ngôn ngữ lập trình bậc cao bổ sung thêm các cấu trúc lệnh chọn,
lệnh lặp, khái niệm khối, chương trình con và đệ quy. Những thành tố này được
sử dụng rộng rãi và thay thế dần cho lệnh nhảy (yếu tố dễ làm chương trình mất
kiểm soát).
Hầu hết các nhà khoa học máy tính đều thừa nhận rằng việc học và áp dụng các
khái niệm lập trình cấu trúc là rất hữu ích, tuy vậy vẫn còn nhiều tranh cãi xung
quanh việc chấm dứt hay duy trì sự tồn tại của lệnh nhảy. C++ hỗ trợ đầy đủ các
thành phần của lập trình cấu trúc và cũng duy trì lệnh nhảy, bởi trong vài trường
hợp, việc cố gắng thay thế lệnh nhảy một cách cứng nhắc sẽ làm chương trình
phức tạp thêm đáng kể.
Trong phần này, ngoài các khái niệm của lập trình cấu trúc, ta cũng sẽ làm quen
với khái niệm bài toán trong tin học, thuật toán và phương pháp tinh chế từng
bước trong lập trình chuyển giao thuật toán cho máy tính.
Phần III
Lập trình cấu trúc
74
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Chương trình 3
1 | #include <iostream> Cho a, b = 3 2
2 | using namespace std; Nghiem x = -0.666667
3 |
4 | int main()
5 | {
6 | double a, b;
7 | cout << "Cho a, b = ";
8 | cin >> a >> b;
9 | cout << "Nghiem x = " << -b / a;
10 | }
Cấu trúc tuần tự thể hiện trong khối lệnh của hàm main:
Dòng 6 khai báo hai biến số thực double tên 𝑎 và 𝑏.
Dòng 7 là lệnh in ra màn hình dòng chữ “Cho a, b = ”.
Dòng 8 là lệnh đọc dữ liệu, chờ người dùng nhập vào hai giá trị số thực, sau đó giá
trị thứ nhất được gán cho biến 𝑎, giá trị thứ hai được gán cho biến 𝑏
Dòng 9 là lệnh in ra màn hình dòng chữ “Nghiem x = ”, tiếp theo là giá trị của biểu
thức −𝑏/𝑎 ứng với hai giá trị 𝑎, 𝑏 vừa nhập vào.
8.2.2. Tính diện tích hình tròn
Ví dụ tiếp theo là chương trình tính diện tích hình tròn biết đường kính của nó. Ta
𝑑2
biết rằng hình tròn đường kính 𝑑 có diện tích bằng 𝜋 × . Giá trị số 𝜋 có thể viết
4
xấp xỉ trong hệ thập phân, tuy nhiên làm như vậy dễ nhầm lẫn.
Thư viện cmath cung cấp rất nhiều hàm, trong đó có các hàm lượng giác và hàm
ngược của chúng:
Tên hàm Tham số Giá trị kết quả Tên hàm toán học
std::cos(α) 𝛼 ∈ (−∞; +∞) ∈ [−1; +1] Cosine of α
std::sin(α) 𝛼 ∈ (−∞; +∞) ∈ [−1; +1] Sine of α
std::tan(α) 𝛼 ∈ (−∞; +∞) ∈ [−∞; +∞] Tangent of α
std::acos(t) 𝑡 ∈ [−1; +1] ∈ [0; 𝜋] Arccosine of t
std::asin(t) 𝑡 ∈ [−1; +1] ∈ [−𝜋/2; +𝜋/2] Arcsine of t
std::atan(t) 𝑡 ∈ [−∞; +∞] ∈ [−𝜋/2; +𝜋/2] Arctangent of t
std::atan2(x, y) 𝑥, 𝑦 ∈ [−∞; +∞] ∈ [−𝜋/2; +𝜋/2]
Phần III
Lập trình cấu trúc
*Trên đường tròn đơn vị (bán kính 1), lấy một cung tròn độ dài 1, góc ở tâm tương ứng với cung
đó có độ lớn đúng 1 radian.
76
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Chương trình 4
1 | #include <iostream> Cho duong kinh: 2
2 | #include <cmath> Dien tich: 3.14159
3 | using namespace std;
4 | const long double Pi = atan(1.L) * 4;
5 |
6 | int main ()
7 | {
8 | long double d;
9 | cout << "Cho duong kinh: ";
10 | cin >> d;
11 | cout << "Dien tich: " << d * d / 4 * Pi;
12 | }
Biến 𝑑 kiểu long double được khai báo trong khối lệnh của hàm main, trong khi
hằng Pi được khai báo bên ngoài. Một khai báo không nằm trong bất kỳ hàm nào
gọi là khai báo toàn cục (global). Những định danh (tên) khai báo toàn cục có thể
dùng ở bất cứ đâu trong chương trình, còn những định danh khai báo trong khối
lệnh chỉ được dùng nội bộ trong khối lệnh đó mà thôi. Trong chương trình đơn
giản này chỉ có một hàm, có thể khai báo hằng Pi trong khối lệnh hàm main hoặc
khai báo biến 𝑑 là biến toàn cục đều được.
Lý do cách tính hằng 𝜋 = atan(1) × 4 được chọn mà không dùng acos(−1) khá
đặc biệt. Thực ra trong các bộ đồng xử lý toán học chuyên tính toán số thực, cách
viết acos(−1) không những đúng mà còn ngắn gọn hơn. Tuy nhiên khi tính toán
và kể cả gán giá trị số thực, người ta luôn đề phòng một sai số nhất định do không
biết trình dịch và bộ xử lý làm gì bên trong. Nếu giá trị −1 vì một lý do nào đó bị
giảm đi dù chỉ một lượng rất nhỏ, nó sẽ nằm ngoài khoảng phạm vi chấp nhận cho
tham số ([−1; +1]), khiến cho phép tính trở thành hành vi không xác định (thông
thường hàm acos() sẽ trả về NAN nếu tham số nằm ngoài khoảng [−1; +1]).
Khi in một giá trị biểu thức số thực, mặc định luồng xuất chuẩn sẽ in ra tối đa 6 chữ
số. Muốn in ra khuôn dạng số thập phân với 𝑘 chữ số sau dấu chấm thập phân, ta
dùng lệnh:
std::cout << std::fixed << std::setprecision(k);
Lệnh này đẩy ra std::cout hai đối tượng: std::fixed để thiết lập in số thực theo
khuôn dạng thập phân thông thường, tiếp theo là đẩy ra std::cout đối tượng
std::setprecision(𝑘) để thiết lập in số thực với 𝑘 chữ số sau dấu chấm thập phân.
Sau khi đã thiết lập, tất cả giá trị số thực đẩy ra std::cout sẽ được in ra dưới khuôn
dạng đó. Chú ý nạp thư viện iomanip để dùng được std::setprecision(. ).
Phần III
Lập trình cấu trúc
Chương trình 5
1 | #include <iostream> Cho duong kinh: 2
2 | #include <cmath> Dien tich: 3.1415926535897932
3 | #include <iomanip>
4 | using namespace std;
5 | const long double Pi = atan(1.L) * 4;
6 |
7 | int main ()
8 | {
9 | long double d;
10 | cout << "Cho duong kinh: ";
11 | cin >> d;
12 | cout << fixed << setprecision(16);
13 | cout << "Dien tich: " << d * d / 4 * Pi;
14 | }
Ta mới làm quen với hàm chuẩn std::atan. C++ còn rất nhiều hàm chuẩn trong các
thư viện nữa. Nhưng cần chú ý là những hàm đó thường có 2 phiên bản: Phiên bản
dành cho C (không có std::) và phiên bản dành cho C++ (có std::). Chẳng hạn có
hai hàm khác nhau của thư viện cmath: Hàm atan và hàm std::atan, hàm atan luôn
trả về giá trị kiểu double còn hàm std::atan trả về giá trị cùng kiểu với tham số nếu
tham số thuộc kiểu số thực. Điều này cũng tương tự với các hàm khác như hàm lấy
căn (sqrt và std::sqrt), hàm lấy giá trị tuyệt đối (abs và std::abs). Ta nên dùng phiên
bản của C++ có nhiều tính năng hơn (khi đã khai báo using namespace std; thì chắc
chắn phiên bản được dùng là dành cho C++)
8.3. Cấu trúc ghép
Khi các câu lệnh được viết liên tiếp tạo thành một khối lệnh và được đặt vào giữa
cặp ngoặc nhọn ({… }), chúng được coi như một lệnh duy nhất gọi là lệnh ghép.
Những lệnh nằm trong cặp dấu ngoặc nhọn được gọi là lệnh thành phần của lệnh
ghép.
Công dụng của lệnh ghép là ở một số nơi trong chương trình chỉ được viết một
lệnh duy nhất, nếu chúng ta muốn thi hành nhiều lệnh, cần phải “gói” chúng vào
thành một lệnh ghép.
Lệnh ghép không cần kết thúc bởi dấu chấm phẩy “;” (dù có thừa cũng không sao).
Phần nằm giữa hai dấu ngoặc nhọn là khối lệnh của lệnh ghép. Những định danh
(hằng, biến, …) khai báo bên trong một khối lệnh chỉ được dùng bên trong khối
lệnh đó mà thôi.
78
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
được dùng để diễn tả câu: “Nếu «Điều kiện» đúng thì làm «Lệnh»”. Hình 4-1 là sơ
đồ thực hiện của lệnh if…
Bắt đầu
false
«Điều kiện»
true
«Lệnh»
Kết thúc
Ví dụ 1 (if…)
Viết chương trình cho nhập vào một số thực 𝑠 là điểm của một học sinh. In ra màn
hình câu “Chuc mung” nếu 𝑠 ≥ 9.
Chương trình 6
1 | #include <iostream> Diem = 9.6
2 | using namespace std; Chuc mung
3 |
4 | int main()
5 | {
6 | double s;
7 | cout << "Diem = ";
8 | cin >> s;
9 | if (s >= 9)
10 | cout << "Chuc mung";
11 | }
Trong Chương trình 6, có 4 lệnh thi hành tuần tự, bao gồm các lệnh đơn giản
ở các dòng 6, 7, 8 và một lệnh if viết trên hai dòng 9, 10.
Lệnh ở dòng 10 gọi là lệnh thành phần của lệnh if. Lệnh thành phần có thể viết trên
cùng dòng, nhưng nếu viết trên một dòng mới thì nên viết lùi vào thêm 1 khoảng
cố định (indent) để chương trình dễ đọc hơn, như ở Chương trình 6 là 4 dấu
cách. Ta sẽ nói kỹ về nguyên tắc trình bày chương trình trong mục NEEDREF.
Ví dụ 2 (if…): Giải và biện luận phương trình bậc 2
Giải và biện luận phương trình bậc 2 với hệ số và nghiệm thực
𝑎𝑥 2 + 𝑏𝑥 + 𝑐 = 0 (𝑎 ≠ 0)
Chương trình cần cho nhập vào ba số thực 𝑎, 𝑏, 𝑐 trong đó 𝑎 ≠ 0. In ra nghiệm và
biện luận cho phương trình tương ứng.
80
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Thuật toán giải và biện luận phương trình bậc hai đã được cung cấp trong các kiến
thức đại số. Ta nhắc lại để chuyển thành chương trình:
Đầu tiên tính Δ = 𝑏 2 − 4𝑎𝑐
Nếu Δ < 0: Phương trình vô nghiệm
𝑏
Nếu Δ = 0: Phương trình có nghiệm kép 𝑥 = − 2𝑎
Nếu Δ > 0: Phương trình có hai nghiệm phân biệt:
−𝑏 + √Δ
𝑥1 =
2𝑎
−𝑏 − √Δ
𝑥2 =
2𝑎
Chương trình cần khai báo ba biến số thực 𝑎, 𝑏, 𝑐 và cho phép nhập vào giá trị cho
chúng, đây là các hệ số của phương trình. Ta khai báo thêm một biến số thực 𝐷𝑒𝑙𝑡𝑎
và gán giá trị cho nó bằng 𝑏 2 − 4𝑎𝑐. Chương trình sau đó chỉ gồm 3 lệnh if:
if (Delta < 0)…
if (Delta == 0)…
if (Delta > 0)…
Ngoài ra, thuật toán yêu cầu phải thực hiện phép khai căn bậc 2. Thư viện cmath
hỗ trợ hàm std::sqrt(𝑥) để tính căn bậc 2 của 𝑥, để sử dụng hàm std::sqrt ta chỉ cần
thêm dòng #include <cmath> vào đầu chương trình.
Kết quả hàm std::sqrt(𝑥) cùng kiểu với 𝑥 nếu 𝑥 thuộc một trong các kiểu số thực,
kết quả hàm thuộc kiểu double nếu 𝑥 thuộc kiểu số nguyên.
Chương trình 7
1 | #include <iostream> Cho a, b, c: 2 3 4
2 | #include <cmath> //sqrt Vo nghiem
3 | #include <iomanip> //setprecision
4 | using namespace std; Cho a, b, c: 4 4 1
5 | Nghiem kep x = -0.5000
6 | int main()
7 | { Cho a, b, c: 1 -5 6
8 | double a, b, c; x1 = 3.0000
9 | cout << "Cho a, b, c: "; x2 = 2.0000
10 | cin >> a >> b >> c;
11 | double Delta = b * b - 4 * a * c;
12 | cout << fixed << setprecision(4);
13 | if (Delta < 0)
14 | cout << "Vo nghiem";
15 | if (Delta == 0)
16 | cout << "Nghiem kep x = " << -b / (2 * a);
17 | if (Delta > 0)
18 | {
19 | double x1 = (-b + sqrt(Delta)) / (2 * a);
20 | double x2 = (-b - sqrt(Delta)) / (2 * a);
21 | cout << "x1 = " << x1 << '\n'
22 | << "x2 = " << x2;
23 | }
24 | }
Phần III
Lập trình cấu trúc
Trong mã lệnh hàm main của Chương trình 7 dòng 8 cho đến dòng 12 không
có gì đặc biệt: Nhập vào ba biến số thực 𝑎, 𝑏, 𝑐 và tính 𝐷𝑒𝑙𝑡𝑎 = 𝑏 2 − 4𝑎𝑐.
Lệnh ở dòng 14: cout << "Vo nghiem"; chỉ được thi hành khi điều kiện Delta < 0 ở
dòng 13 là đúng.
Lệnh ở dòng 16: cout << "Nghiem kep x = " << -b / (2 * a); chỉ được thi hành khi
Delta == 0 (dòng 15)
Từ dòng 18 đến dòng 23 là một lệnh ghép, nó chỉ được thi hành khi Delta > 0 (dòng
17). Lệnh ghép này thi hành tuần tự ba lệnh thành phần: Tính nghiệm 𝑥1, tính
nghiệm 𝑥2, sau đó in ra hai nghiệm cách nhau bởi dấu xuống dòng. Vì có tới 3 lệnh,
để trở thành lệnh thành phần của if (Delta > 0), chúng phải được gói lại thành
một lệnh ghép bằng cặp dấu ngoặc nhọn {…}
8.4.2. Lệnh if…else…
Cú pháp:
if («Điều kiện»)
«Lệnh 1»
else
«Lệnh 2»
if và else là từ khóa
«Điều kiện» là một biểu thức bool, cặp dấu ngoặc đơn là bắt buộc.
«Lệnh 1» là một lệnh thành phần của cấu trúc, sẽ được thi hành nếu «Điều kiện»
đúng.
«Lệnh 2» cũng là một lệnh thành phần của cấu trúc, sẽ được thi hành nếu «Điều
kiện» sai.
Khi gặp cấu trúc if…else…, chương trình sẽ tính giá trị biểu thức «Điều kiện»:
Nếu giá trị tính được bằng true, chương trình sẽ thi hành «Lệnh 1»
Nếu giá trị tính được là false, chương trình sẽ thi hành «Lệnh 2».
Toàn bộ cấu trúc if («Điều kiện») «Lệnh 1» else «Lệnh 2» cũng được coi là 1 lệnh.
Khi cài đặt chương trình cho một thuật toán diễn tả bằng ngôn ngữ tự nhiên, cấu
trúc này dùng để diễn tả câu: “Nếu «Điều kiện» đúng thì làm «Lệnh 1», nếu không
làm «Lệnh 2»”.
Hình 4-1 là sơ đồ thực hiện của lệnh if…else…
82
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Bắt đầu
false
«Điều kiện»
true
«Lệnh 1» «Lệnh 2»
Kết thúc
Chương trình 8
1 | #include <iostream> Nam: 2004
2 | using namespace std; 2004 la nam nhuan
3 |
4 | int main() Nam: 2019
5 | { 2019 khong phai nam nhuan
6 | int y;
7 | cout << "Nam: ";
8 | cin >> y;
9 | if (y % 400 == 0 || (y % 4 == 0 && y % 100 != 0))
10 | cout << y << " la nam nhuan";
11 | else
12 | cout << y << " khong phai nam nhuan";
13 | }
Các dòng từ 9 tới 12 có thể viết gộp bằng biểu thức điều kiện:
cout << y
<< (y % 400 == 0 || (y % 4 == 0 && y % 100 != 0) ? " la": " khong phai")
<< " nam nhuan";
tuy nhiên cách viết bằng lệnh if… tỏ ra sáng sủa hơn. Nói chung biểu thức điều kiện
chỉ nên dùng khi nó đơn giản, lệnh if…else… luôn là sự lựa chọn tốt và không thể
thay thế trong trường hợp hai lệnh thành phần là những lệnh phức tạp.
Để ý biểu thức điều kiện:
y % 400 == 0 || (y % 4 == 0 && y % 100 != 0)
Cặp dấu ngoặc đơn trong trường hợp này là thừa, do phép toán && ưu tiên hơn
phép toán ||. Cặp dấu ngoặc đơn này được thêm vào chỉ để đọc biểu thức dễ dàng
hơn.
Phần III
Lập trình cấu trúc
Chương trình 9
1 | #include <iostream> Cho a, b, c, d: 1 8 3 10
2 | using namespace std; Do dai phu = 9
3 |
4 | int main() Cho a, b, c, d: 1 2 3 4
5 | { Do dai phu = 2
6 | double a, b, c, d;
7 | cout << "Cho a, b, c, d: ";
8 | cin >> a >> b >> c >> d;
9 | cout << "Do dai phu = ";
10 | if (b < c || d < a) //2 đoạn rời nhau
11 | cout << b - a + d - c;
12 | else //2 đoạn giao nhau
13 | {
14 | double L = a < c ? a : c; //min(a, c)
15 | double R = b > d ? b : d; //max(b, d)
16 | cout << R - L;
17 | }
18 | }
Thư viện algorithm có sẵn hàm std::min và hàm std∷max để tính min và max của
hai biểu thức cùng kiểu. Ta cũng có thể sử dụng những hàm này để viết lại
Chương trình 9 cho gọn.
84
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
1 | if (b != 0)
2 | if (a % b == 0) std::cout << "So nguyen";
3 | else;
4 | else cout << std::"Khong xac dinh";
Phần else ở dòng 3 có lệnh thành phần là lệnh rỗng. Dấu chấm phẩy chỉ có ý nghĩa
giữ cho phần else ở dòng 3 tồn tại mà không gặp lỗi biên dịch. Sự tồn tại của phần
else ở dòng 3 khiến cho nó được gắn với phần if ở dòng 2, kéo theo việc phần else
ở dòng 4 sẽ được gắn với phần if ở dòng 1.
Tuy nhiên, làm như vậy khiến cho chương trình thêm rối, cách đơn giản hơn là sử
dụng cấu trúc ghép để gói lệnh if… ở dòng 2 lại, không cho có cơ sở để dính dáng
tới phần else cuối cùng:
1 | if (b != 0)
2 | {
3 | if (a % b == 0) std::cout << "So nguyen";
4 | }
5 | else std::cout << "Khong xac dinh";
Bây giờ trình dịch đã hiểu đoạn lệnh này là một cấu trúc if…else…, phần if có lệnh
thành phần là một lệnh ghép {…}, và phần else có lệnh thành phần là lệnh
std::cout << ….
Khi cấu trúc if…else… lồng nhau quá phức tạp, dù cho đoạn code hoạt động đúng,
đôi khi các lập trình viên vẫn gói từng đoạn lệnh vào trong các lệnh ghép để phân
biệt rõ các khối lệnh.
Ta xét một ví dụ khác: Viết chương trình nhập vào hai số thực 𝑎, 𝑏. Giải và biện
luận phương trình:
𝑎𝑥 + 𝑏 = 0
Trong trường hợp 𝑎 ≠ 0 , đây là một phương trình bậc nhất và ta đã giải ở
Chương trình 3. Bài toán này không có ràng buộc 𝑎 ≠ 0 nên cần biện luận cho
trường hợp 𝑎 = 0 nữa:
Nếu 𝑎 ≠ 0: In ra nghiệm duy nhất 𝑥 = −𝑏/𝑎
Nếu không: (𝑎 = 0)
Nếu 𝑏 = 0: Thông báo phương trình có vô số nghiệm
Nếu không (𝑏 ≠ 0): Thông báo phương trình vô nghiệm
Cấu trúc trên được chuyển ra chương trình một cách tự nhiên:
86
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
theo «Biểu thức» bằng trường hợp nào trong số các «Hằng 1», «Hằng 2», …, thi hành
các nhóm lệnh tuần tự bắt đầu từ nhóm lệnh tương ứng với hằng đó (nếu không có
trường hợp nào thỏa mãn thì bắt đầu từ nhóm lệnh mặc định) cho tới khi gặp break;
hoặc đã thi hành hết các nhóm lệnh”.
Lý do «Biểu thức» và các «Hằng…» không được phép thuộc kiểu số thực vì kiểu số
thực luôn tiềm ẩn sai số khi tính toán cũng như khi chuyển đổi kiểu, điều này có
thể gây nhầm lẫn cho phép so sánh bằng nhau.
Hình 4-1 là sơ đồ khối của lệnh switch…, hình vẽ này không hoàn toàn đúng với
các quy chuẩn về sơ đồ khối (mỗi khối chữ nhật chứa nhóm lệnh lại có 2 đường
ra). Lý do là vì lệnh switch… không thể biểu diễn sơ đồ khối chuẩn nếu nó chứa
break; - một lệnh nhảy phá vỡ tính cấu trúc. Ta sẽ trình bày về các lệnh nhảy trong
phần NEEDREF.
Bắt đầu
𝐸 = «Biểu thức»;
false
true
𝐸 == «Hằng 1» «Nhóm lệnh 1»
false break;
true
𝐸 == «Hằng 2» «Nhóm lệnh 2»
break;
false
true
… … break;
false break;
88
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Công thức quan niệm không có tháng 1 và tháng 2. Tháng 1 và tháng 2 được coi là
tháng 13 và tháng 14 của năm trước. Vì vậy nếu tháng (𝑚) nhỏ hơn 3, ta cần cộng
thêm nó với 12 và giảm năm (𝑦) đi 1:
if (m < 3)
{
m += 12;
--y;
}
Công thức sử dụng khái niệm “thế kỷ” nhưng không giống với khái niệm trong
dương lịch: Năm 𝑦 thuộc thế kỷ ⌊𝑦/100⌋, tức là thế kỷ 0 tính từ năm 0 tới năm 99,
thế kỷ 1 tính từ năm 100 đến năm 199, …, thế kỷ 20 tính từ năm 2000 tới năm
2099. Các năm trong thế kỷ được đánh số từ 0 đến 99, tức là năm 𝑦 là năm thứ
𝑦 % 100 trong thế kỷ ⌊𝑦/100⌋
Gọi 𝑐 là thế kỷ có chứa năm 𝑦: 𝑐 = ⌊𝑦/100⌋
Gọi 𝑟 là số hiệu của năm 𝑦 trong thế kỷ 𝑐: 𝑟 = 𝑦 % 100
Công thức Zeller:
13(𝑚 + 1) 𝑟 𝑐
𝐶𝑜𝑑𝑒 = 𝑑 + ⌊ ⌋ + 𝑟 + ⌊ ⌋ + ⌊ ⌋ + 5𝑐
5 4 4
Khi đó tùy theo số dư của 𝐶𝑜𝑑𝑒 khi chia cho 7 (𝐶𝑜𝑑𝑒 % 7), nếu là 0 tương ứng với
thứ bảy, 1 tương ứng với chủ nhật, 2 tương ứng với thứ hai, …
90
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Bắt đầu
false
«Điều kiện»
true
«Lệnh»
Kết thúc
Ví dụ 1 (while…)
Viết chương trình cho nhập vào số nguyên dương 𝑛 (1 ≤ 𝑛 ≤ 2.109 ), tìm lũy thừa
lớn nhất của 10 thỏa mãn 10𝑘 ≤ 𝑛.
Phần III
Lập trình cấu trúc
92
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Viết chương trình cho nhập vào một số tự nhiên 𝑛 (𝑛 ≤ 109 ) và kiểm tra xem 𝑛 có
phải là số nguyên tố hay không.
Để đơn giản, ta xét trường hợp 𝑛 ≥ 2 trước. Có rất nhiều thuật toán để kiểm tra
tính nguyên tố, đơn giản nhất là dựa vào định nghĩa: Số tự nhiên 𝑛 ≥ 2 là số
nguyên tố nếu nó thỏa mãn điều kiện: 𝑛 không có ước số nào nằm trong phạm vi
[2; 𝑛 − 1]
94
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Điều kiện d <= √n có thể viết lại thành d * d <= n để tránh sử dụng phép căn số
thực (phép tính chậm, có sai số và sinh lỗi nếu như 𝑛 âm)*.
Ta cần thêm một xử lý nhỏ nữa cho trường hợp 𝑛 < 2. Khi 𝑛 < 2 thì điều kiện lặp
không thỏa mãn ngay từ đầu, vòng lặp while… ở dòng 2 sẽ thoát ngay với giá trị
khởi tạo 𝑑 = 2 (≰ √𝑛) dẫn tới kết luận sai: 𝑛 là số nguyên tố. Để xử lý nốt trường
hợp 𝑛 < 2, ta chỉ cần thay đổi điều kiện của lệnh if ở dòng 3 thành:
if (n < 2 || d * d <= n) «Kết luận n không nguyên tố»
*Điều kiện d <= √n ngoài cách viết tương đương d * d <= n còn có thể viết thành d <= n / d để
tránh luôn cả nguy cơ tràn số của phép nhân nếu ràng buộc dữ liệu thay đổi và cho phép nhập vào
giá trị lớn nhất trong kiểu số nguyên cho 𝑛. Tuy nhiên cách viết d * d <= n vẫn hay được dùng hơn
so với d <= n / d nếu như chắc chắn phép nhân không bị tràn số, lý do là mã máy của phép chia
chậm hơn mã máy của phép nhân
Phần III
Lập trình cấu trúc
Chứng minh
Nếu 𝑏 = 0, hiển nhiên GCD(𝑎, 𝑏) = 𝑎
Nếu 𝑏 ≠ 0, gọi 𝑞 và 𝑟 lần lượt là thương và số dư của phép chia 𝑎 cho 𝑏 (𝑞, 𝑟 ∈ ℤ)
𝑎 = 𝑞𝑏 + 𝑟
Với một số nguyên 𝑑 ≠ 0 bất kỳ, chia cả 2 vế cho 𝑑:
𝑎 𝑏 𝑟
=𝑞× +
𝑑 𝑑 𝑑
𝑎 𝑏 𝑟
Nếu 𝑑 ước chung của cả 𝑎 và 𝑏: ta có và đều là số nguyên, vậy cũng phải
𝑑 𝑑 𝑑
là số nguyên, suy ra 𝑑 là ước chung của 𝑏 và 𝑟.
𝑏 𝑟 𝑎
Nếu 𝑑 là ước chung của cả 𝑏 và 𝑟 thì và 𝑑 đều là số nguyên, vậy 𝑑 cũng phải là
𝑑
số nguyên, suy ra 𝑑 là ước chung của 𝑎 và 𝑏.
Vậy tập các ước chung của 𝑎 và 𝑏 trùng với tập các ước chung của 𝑏 và 𝑟. Hai tập
trùng nhau thì giá trị lớn nhất phải bằng nhau. Ta có GCD(𝑎, 𝑏) = GCD(𝑏, 𝑟).
Thuật toán Euclid:
Bắt đầu: Nhận vào 2 số 𝑎, 𝑏
Lặp: Chừng nào 𝑏 ≠ 0, tính 𝑟 là số dư của phép chia 𝑎 cho 𝑏 và thay cặp (𝑎, 𝑏)
bởi cặp (𝑏, 𝑟).
Kết thúc: Khi 𝑏 = 0, giá trị 𝑎 là ước số chung lớn nhất của hai số ban đầu
Ta thực nghiệm thuật toán này bằng tay với một dữ liệu cụ thể: 𝑎 = 76; 𝑏 = 172
Bước lặp (a, b) r = a % b
1 (76, 172) 76
2 (172, 76) 20
3 (76, 20) 16
4 (20, 16) 4
5 (16, 4) 0
6 (4, 0)
Tính đúng đắn của thuật toán đã được chứng minh, ta sẽ chỉ ra tính dừng của thuật
toán, tức là vòng lặp của thuật toán không bao giờ bị rơi vào một quá trình vô hạn
mà sẽ kết thúc sau hữu hạn lượt lặp.
Với giả thiết 𝑎, 𝑏 không âm. Khi 𝑏 ≠ 0, số dư 𝑟 của phép chia 𝑎 cho 𝑏 chắc chắn
thỏa mãn 𝑟 < 𝑏. Vì vậy nếu thay cặp (𝑎, 𝑏) bởi cặp (𝑏, 𝑟), giá trị số thứ hai trong
cặp sẽ giảm đi nhưng không bao giờ âm được. Vì vậy quá trình này phải kết thúc
sau hữu hạn bước.
Việc đánh giá hiệu suất của thuật toán liên quan tới cả một lý thuyết lớn về độ phức
tạp tính toán, ta sẽ không trình bày ở đây.
96
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Cấu trúc do…while…, cũng được coi là lệnh do…while…, chỉ định cho chương trình
thực hiện việc: Thi hành «Lệnh», tính giá trị biểu thức «Điều kiện» rồi lặp lại nếu
giá trị tính được là true, nếu không, kết thúc lệnh do…while…
Chú ý rằng «Lệnh» sẽ được thi hành ít nhất một lần trong cấu trúc này.
Khi cài đặt chương trình cho một thuật toán diễn tả bằng ngôn ngữ tự nhiên, cấu
trúc do…while… được dùng để diễn tả câu: “Làm «Lệnh» và lặp lại chừng nào thấy
«Điều kiện» đúng” hoặc “Làm «Lệnh» và lặp lại cho tới khi «Điều kiện» sai”. Hình 8-5
là sơ đồ thực hiện của lệnh do…while…
Bắt đầu
«Lệnh»
true
«Điều kiện»
false
Kết thúc
Ví dụ 1 (do…while…)
Những chương trình đã viết đều là cài đặt thuật toán chạy một lần, muốn thực hiện
lại thuật toán, người dùng phải chạy lại chương trình với nhiều thao tác.
Ta có thể lồng chương trình cài đặt thuật toán chạy một lần vào một cấu trúc
do…while…: Khi kết thúc, đưa ra câu hỏi cho người sử dụng: “Có muốn làm tiếp
không?” và nếu người sử dụng nhập vào đúng ký tự ‘c’ (hoặc ‘C’), chương trình sẽ
được cho chạy lặp lại từ đầu. Vòng lặp này kết thúc nếu như người sử dụng nhập
vào một ký tự khác. Sơ đồ như sau:
char answer; //Biến nhận ký tự trả lời
do
{
«Đoạn chương trình cài đặt thuật toán chạy một lần»
cout << "Co muon tiep khong? ";
cin >> answer;
}
while (answer == 'c' || answer == 'C');
Ví dụ ta lồng đoạn chương trình tính ước số chung lớn nhất của hai số (Chương
trình 15) vào trong sơ đồ này.
98
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Khi còn lại một chữ số cuối cùng 𝑑𝑘 (𝑛 = 𝑑𝑘 ), chữ số này được xét và xử lý, sau
đó công thức 𝑛 /= 10 cho ta giá trị 𝑛 = 0, đây là điều kiện để kết thúc vòng lặp
(𝑛 == 0).
100
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
𝑛= 0 𝑑𝑘−1 𝑑𝑘−2 … 𝑑1 𝑑0
𝑝= 1 0 … 0 0
Lặp lại với giá trị 𝑛 mới, để tách chữ số 𝑑𝑘−1 ta lại dùng công thức 𝑛/𝑝, sau đó lại
thay chữ số 𝑑𝑘−1 bởi 0 (𝑛 %= 𝑝) và giảm 𝑝 đi 10 lần (𝑝 /= 10):
𝑛= 0 0 𝑑𝑘−2 … 𝑑1 𝑑0
𝑝= 1 … 0 0
… Ở lượt lặp cuối cùng, ta có 𝑝 = 1, sau khi tách nốt chữ số hàng đơn vị, lệnh
𝑝 /= 10 cho ta 𝑝 = 0, đây là điều kiện để dừng vòng lặp.
Chương trình sau đây cài đặt đúng ý tưởng đó
int main ()
{
int n;
cout << "Cho n = ";
cin >> n;
//Tìm p = lũy thừa lớn nhất của Radix ≤ n
int p = 1;
while (p <= n / Radix) p *= Radix;
do //Vòng lặp tách chữ số
{
cout << n / p; //In ra 1 chữ số
n %= p; //Thay chữ số vừa tách bởi 0
p /= Radix;
}
while (p != 0); //p ≠ 0 thì tách tiếp
}
Muốn tìm biểu diễn của số tự nhiên 𝑛 trong hệ cơ số khác, đơn giản chỉ cần đổi
hằng số 𝑅𝑎𝑑𝑖𝑥.
102
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Bắt đầu
«Khởi tạo»;
false
«Điều kiện»
true
«Lệnh»
«Cập nhật»;
Kết thúc
104
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
106
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
while…
if…
switch…
for…
do…while…
Lệnh gán ở dòng 13 ID = 3; nằm trong khối B và sau vị trí khai báo. Lệnh gán ở
dòng 14 Global = ID; nằm trong khối con của khối B nằm sau vị trí khai báo. Chúng
là những lệnh gán đúng vì nhìn thấy biến ID (lệnh gán ở dòng 14 đúng vì nhìn thấy
cả biến Global).
Một điều cần chú ý nữa là khi chương trình chạy ra khỏi một khối, tất cả tên trong
khối đó cùng với các đối tượng tương ứng sẽ bị hủy. Có nghĩa là nếu một khối được
chạy nhiều lần (trong vòng lặp chẳng hạn), lần chạy sau không được tận dụng giá
trị biến ở lần chạy trước nếu biến đó được khai báo trong khối. Ví dụ:
1 | #include <iostream>
2 | using namespace std;
3 |
4 | int main ()
5 | {
6 | for (int i = 1; i <= 10; ++i)
7 | {
8 | int x;
9 | if (i == 1) x = 0; //OK
10 | cout << x++; //Hành vi không xác định
11 | }
12 | }
Lưu ý là trong chương trình trên, biến 𝑖 nằm trong khối lệnh của lệnh for… (từ
dòng 6 tới dòng 11) còn biến 𝑥 nằm trong khối lệnh của lệnh ghép (từ dòng 7 tới
dòng 11). Kể từ lượt lặp với 𝑖 = 2 trở đi, biến 𝑥 được khai báo nhưng chưa khởi
tạo giá trị, biểu thức x++ trở thành hành vi không xác định.
8.6.2. Phạm vi xác định của tên
Nếu ta khai báo nhiều tên (định danh) trong một khối, các tên này phải hoàn toàn
phân biệt. Nhưng hai tên nằm trong hai khối khác nhau có thể trùng nhau. Câu hỏi
đặt ra là nếu tên ID được khai báo trong nhiều khối, khi một lệnh S dùng đến tên
ID nó sẽ xác định đó là ID trong khối nào?
Quy tắc là trình dịch sẽ tìm ID trong khối chứa lệnh S trước (gọi là khối B), nếu
nhìn thấy tên ID trong khối, nó sẽ dùng luôn cho lệnh S. Nếu không nhìn thấy nó sẽ
tìm ra ngoài khối chứa khối B, và cứ tiếp tục như vậy… Nếu tìm đến khối toàn cục
mà vẫn không nhìn thấy ID, trình dịch sẽ báo lỗi.
Xét hai chương trình dưới đây
108
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
vậy, việc cấm tuyệt đối sử dụng các lệnh nhảy khiến cho việc lập trình đôi lúc gặp
khó khăn, C++ vẫn cho phép dùng lệnh nhảy nhưng với một số ràng buộc hạn chế*.
8.7.1. Lệnh nhảy goto
Để sử dụng lệnh nhảy goto, ta cần chọn một «Tên nhãn» (label). Dùng «Tên nhãn»
và dấu hai chấm “:” đánh dấu vị trí trước một lệnh. Lệnh bắt buộc phải có sau «Tên
nhãn» và dấu “:”, nếu vị trí đánh dấu đang không có lệnh nào đứng sau, ta có thể
dùng dấu “;” để coi như phía sau nhãn có một lệnh rỗng.
Khi chương trình gặp lệnh
goto «Tên nhãn»;
sẽ nhảy ngay đến vị trí đánh dấu và thi hành các lệnh tiếp từ đó…
Ràng buộc: Không được nhảy qua các khai báo. Điều này dễ hiểu vì nếu nhảy qua
các khai báo, tên trong các khai báo đó sẽ không tồn tại ở vị trí nhảy đến, các lệnh
thi hành sau khi nhảy nếu có truy cập đến những tên này sẽ là trái logic xử lý của
chương trình.
Ví dụ ta viết lại Chương trình 15 tính ước số chung lớn nhất bằng lệnh goto.
Chương trình viết bằng vòng lặp while được đặt ở bên phải cho tiện so sánh.
1 | #include <iostream> #include <iostream>
2 | using namespace std; using namespace std;
3 |
4 | int main () int main ()
5 | { {
6 | int a, b, r; int a, b, r;
7 | cin >> a >> b; cin >> a >> b;
8 | Repeat: while (b != 0)
9 | if (b == 0) goto Done; {
10 | r = a % b; r = a % b;
11 | a = b; a = b;
12 | b = r; b = r;
13 | goto Repeat; }
14 | Done: cout << a;
15 | cout << a; }
16 | }
Chương trình cứ chạy đến dòng 13 thì nhảy ngược về dòng 8 để thực hiện lặp,
trong quá trình lặp, dòng 9 có kiểm tra điều kiện dừng (b == 0) và nếu điều kiện
này thỏa mãn sẽ nhảy thẳng tới lệnh in kết quả rồi thoát chương trình. Chỉ là một
*Lý do mà người ta chống lại lệnh nhảy một phần chính là các thuật toán cổ điển ban đầu được biểu
diễn bằng sơ đồ khối, lệnh nhảy khiến cho không thể vẽ được sơ đồ khối chuẩn mực khi một khối
lệnh có thể có nhiều lối ra do lệnh nhảy. Tuy nhiên với các thuật toán hiện đại, phương pháp sơ đồ
khối cũng trở nên lạc hậu và chủ yếu người ta dùng mã giả (pseudo code) để phát biểu thuật toán,
lệnh nhảy vẫn được cho phép miễn là không ảnh hưởng tới khả năng kiểm soát chương trình.
110
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
ví dụ đơn giản nhưng có thể thấy lệnh goto khiến cho chương trình tối tăm và khó
kiểm soát hơn hẳn.
Theo định lý Böhm – Jacopini (1966), mọi quá trình tính toán trên máy tính đều có
thể diễn tả bằng ba cấu trúc cơ bản: Tuần tự, lựa chọn và lặp. Chúng ta có thể lập
trình mà không cần lệnh goto.
Tuy goto có thể thay thế được nhưng loại bỏ nó đôi khi sẽ khiến cấu trúc chương
trình cồng kềnh thêm đang kể. Vậy nên:
Tránh làm dụng goto, chỉ dùng trong những trường hợp thực sự khó thay thế
và không làm mã lệnh mất kiểm soát.
Ngôn ngữ lập trình cũng cung cấp vài dạng lệnh nhảy có điều kiện khắt khe hơn
để thay thế cho goto, chúng ta có thể sử dụng chúng cùng với những tiêu chuẩn
đặc biệt*.
8.7.2. Lệnh break; và continue;
Lệnh break;
Khi chương trình gặp break; nó sẽ tìm khối lệnh nhỏ nhất chứa break; mà khối đó
là của lệnh switch hay lệnh lặp, lệnh tương ứng với khối đó sẽ thoát.
Có thể hiểu đơn giản: break; là goto ra sau khối lệnh switch hay lệnh lặp chứa nó.
Nếu nhìn trên sơ đồ khối, lệnh break; có nghĩa là nhảy thẳng đến ô:
Kết thúc
Chú ý là nếu có nhiều khối ứng với các lệnh lặp hay switch lồng nhau, break; chỉ
thoát đúng khối nhỏ nhất chứa nó mà thôi.
Lệnh continue;
Khi chương trình gặp continue;, nó sẽ xác định khối lệnh lặp nhỏ nhất chứa
continue;, lệnh thành phần của vòng lặp sẽ bị thoát, chương trình nhảy đến kiểm
tra điều kiện lặp và thi hành lượt lặp mới nếu điều kiện đúng. Xét trên sơ đồ khối,
lệnh continue; có nghĩa là nhảy thẳng tới ô:
«Điều kiện»
8.7.3. Ví dụ 1
Viết chương trình lặp: Cho nhập vào các giá trị số thực. Khi người dùng nhập vào
số thực 𝑆:
*Nhược điểm chung của các kiểu lệnh nhảy là dễ nhảy qua đoạn mã dọn dẹp, khiến cho chương
trình sinh ra rác trong bộ nhớ, C++ đã khắc phục điều này bằng nhiều kỹ thuật cao cấp mà ta sẽ
trình bày sau.
Phần III
Lập trình cấu trúc
Nếu 𝑆 > 0, in ra thước cạnh hình vuông có diện tích bằng 𝑆 (√𝑆)
Nếu 𝑆 < 0, thông báo nhập dữ liệu sai và bắt nhập lại
Nếu 𝑆 = 0, thoát chương trình
112
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
của vòng lặp. Để in ra một hàng, ta in ra một dấu xuống dòng, sau đó lại thực hiện
một vòng lặp 𝑛 lần: mỗi lần in ra ++𝑐;
phức tạp khi số vòng lặp lồng nhau không chỉ có 2 mà có nhiều hơn nữa, vậy làm
sao để bẻ gãy tất cả các vòng lặp đó từ bên trong. Cách đẹp nhất có thể làm là lệnh
goto.
114
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
các ràng buộc dữ liệu cũng như các yêu cầu chất lượng lời giải đôi khi phải được
ngầm hiểu qua bài toán thực tế: Chẳng hạn tuổi của một người luôn phải là số
không âm, hay số quả trứng trong một giỏ chắc chắn phải là số nguyên và cũng
không bao giờ là số âm được.
Đây không phải là cuốn sách về thuật toán nên ta chỉ trình bày sơ lược các khái
niệm như vậy. Một số ví dụ trong tài liệu này đôi khi sử dụng thẳng mô hình toán
học cho ngắn gọn, như bài toán giải phương trình bậc nhất hay tính diện tích hình
tròn, bởi ứng dụng thực tế của nó đã được môn toán trình bày từ bậc phổ thông.
Một số ví dụ khác dựa trên bài toán thực tế, tuy nhiên mô hình có thể không hoàn
toàn khớp với bài toán đó. Một số ràng buộc có thể bị lược bỏ nếu chúng không
quá quan trọng, cũng có thể sửa đổi cho rõ ràng hơn, cũng có thể tổng quát lên để
tìm giải pháp phổ dụng hơn. Như ví dụ ở mô hình (B), không có khách hàng nào
vào siêu thị mua tới 1 tỉ món hàng (109 ), và cũng không có túi nào của siêu thị dùng
để chứa tới 1 tỉ sản phẩm cả. Nhưng mô hình ấy lại có thể chuyển đổi để xử lý một
bài toán khác: Chẳng hạn người ta muốn phân 𝑛 vi khuẩn vào một số ít nhất các
nhóm để làm thí nghiệm, mỗi nhóm chứa không quá 𝑘 vi khuẩn…, khi ấy số lượng
vi khuẩn lên tới 109 lại là sự kiện rất bình thường.
Ngay cả những ràng buộc hoàn toàn phi thực tế cũng có thể được đưa vào mô hình,
mục đích là để bắt buộc người lập trình phải chọn cách thức khai báo phù hợp và
phải tìm một giải pháp tốt. Làm quen với các bài toán tin học, bạn sẽ được giả định
có những ngôi nhà 1 tỉ tỉ tầng, những thực thể nhanh hơn tốc độ ánh sáng, hay hai
người chơi một trò chơi với các đống sỏi lên tới 1 tỉ viên… Thay vì thắc mắc, hãy
tìm giải pháp cho nó.
8.8.2. Thiết kế thuật toán
Tìm giải pháp cho một bài toán là quá trình thiết kế thuật toán. Thuật toán là một
hệ thống chặt chẽ và rõ ràng các chỉ thị nhằm xác định một dãy thao tác trên dữ
liệu vào sao cho: chỉ cần dữ liệu tuân thủ ràng buộc của bài toán, sau một số hữu
hạn bước thực hiện các thao tác đã chỉ ra, ta đạt được mục tiêu đã định.
Ví dụ 1
Xét bài toán trong mục trước: Một người đi siêu thị mua 𝑛 sản phẩm giống nhau
(0 ≤ 𝑛 ≤ 109 ), mỗi túi của siêu thị có thể gói được tối đa 𝑘 sản phẩm (1 ≤ 𝑘 ≤
109 ). Hỏi khi thanh toán, nhân viên bán hàng của siêu thị cần dùng ít nhất bao
nhiêu túi để gói hàng cho khách.
Yêu cầu: Viết chương trình nhập vào hai số nguyên 𝑛 (số sản phẩm được mua) và
𝑘 (số sản phẩm tối đa có thể đưa vào một túi), cho biết số túi ít nhất cần dùng để
gói hàng.
116
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Thuật toán để giải quyết dựa trên chính hành động thực tế của nhân viên siêu thị:
Lấy túi cho đầy sản phẩm vào, nếu túi đã đầy và còn sản phẩm chưa được gói thì
lấy một túi mới... Như vậy số túi chứa đầy đủ 𝑘 sản phẩm là ⌊𝑛/𝑘⌋, sau khi dùng hết
số lượng túi này thì số sản phẩm còn lại là 𝑛 % 𝑘, nếu con số này khác 0 ta cần thêm
đúng 1 túi nữa. Công thức để tính số túi có thể viết trong C++ là:
n / k + (n % k != 0 ? 1 : 0)
Công thức khác dựa vào bản chất của phép ngầm chuyển đổi kiểu từ bool sang int
(false: 0, true: 1) là:
n / k + (n % k != 0)
hoặc (int→bool→{0,1}):
n / k + bool(n % k)
Tuy nhiên cách viết gọn nhất có thể là:
(n + k - 1) / k
Công thức trên có thể lý giải như sau: Trước khi cho sản phẩm cuối cùng vào túi,
𝑛−1
ta có đúng ⌊ ⌋ túi đầy, dù sản phẩm cuối cùng phải cho vào túi mới hay đưa vào
𝑘
𝑛−1 𝑛+𝑘−1
túi chưa đầy thì ta vẫn cần thêm 1 túi nữa. Kết quả là ⌊ + 1⌋ = ⌊ ⌋. Công
𝑘 𝑘
thức này cũng đúng kể cả khi 𝑛 = 0.
Với ràng buộc dữ liệu 𝑛, 𝑘 ≤ 109 , kiểu số nguyên int là đủ để khai báo hai biến này
cũng như để thực hiện các phép tính cần thiết.
WRAP.cpp ✓
1 | #include <iostream> Cho n, k = 100 6
2 | using namespace std; So tui = 17
3 |
4 | int main()
5 | {
6 | int n, k;
7 | cout << "Cho n, k = ";
8 | cin >> n >> k;
9 | cout << "So tui = " << (n + k - 1) / k;
10 | }
Có một cách tính khác: Số túi cần tìm sẽ là số nguyên 𝑞 nhỏ nhất thỏa mãn 𝑞 × 𝑘 ≥
𝑛, hay nói cách khác 𝑞 là số nguyên nhỏ nhất không nhỏ hơn 𝑛/𝑘. Trong toán học
ký hiệu giá trị này là ⌈𝑛/𝑘⌉, còn trong C++ ta có thể viết là ceil(double(𝑛)/𝑘) để
tính phép chia số thực 𝑛/𝑘 rồi làm tròn lên. Tuy nhiên ta không nên sử dụng các
phép tính số thực chừng nào vẫn còn có thể dùng các phép tính số nguyên để thay
thế, nhằm tránh sai số không đáng có.
Hàm ceil(𝑥): Trả về số nguyên nhỏ nhất ≥ 𝑥 (trần của 𝑥, ký hiệu ⌈𝑥⌉).
Hàm floor(𝑥): Trả về số nguyên lớn nhất ≤ 𝑥 (sàn của 𝑥, ký hiệu ⌊𝑥⌋).
Hai hàm này nằm trong thư viện cmath. Kết quả có kiểu trùng với kiểu của 𝑥 nếu
𝑥 thuộc kiểu số thực, kết quả có kiểu double nếu 𝑥 thuộc kiểu số nguyên. (Chú ý là
mặc dù kết quả nguyên nhưng hai hàm này trả về kiểu số thực). Ví dụ:
Phần III
Lập trình cấu trúc
ceil(3.2) = 4.0
ceil(−3.2) = −3.0
floor(3.6) = 3.0
floor(−3.6) = −4.0
Ví dụ 2
Xét một bài toán khác: Một người nông dân cần quây một khu đất hình chữ nhật
để trồng rau. Diện tích khu đất đó không được nhỏ hơn 𝑛 mét vuông (𝑛 là số
nguyên dương không quá 1018 ) và khi quây xong, người nông dân phải dựng rào
bao quanh khu đất. Vì chiều ngang của mỗi đoạn tường rào mua sẵn dài đúng 1
mét nên để đỡ mất công, anh ta muốn độ dài cạnh của khu đất cũng phải là số
nguyên và để giảm chi phí, anh ta muốn chu vi khu đất nhỏ nhất có thể.
Yêu cầu: Viết chương trình cho nhập vào số 𝑛 và đưa ra độ dài hai cạnh của khu
đất theo phương án tối ưu tìm được.
Bỏ qua những điều phi thực tế như diện tích khu đất trồng rau có thể lên tới 1 tỉ tỉ
mét vuông hay để rào khu đất đó lại cần tới hàng tỉ đoạn tường rào. Ta có thể mô
hình hóa bài toán này như sau:
Dữ liệu được cho (đầu vào – input): Số nguyên dương 𝑛 ≤ 1016
Yêu cầu: Tìm hai số nguyên dương 𝑎, 𝑏 thỏa mãn:
𝑎×𝑏 ≥ 𝑛
{
𝑎 + 𝑏 → min; (𝑎 + 𝑏 nhỏ nhất có thể)
Kết quả cần đưa ra (đầu ra – output): Hai số 𝑎, 𝑏 tìm được chính là số đo hai cạnh
của hình chữ nhật tính bằng mét.
Việc xây dựng thuật toán lại là vấn đề hoàn toàn con người, dựa vào các kỹ năng
toán học và logic. Không giảm tính tổng quát, giả sử 𝑎 ≤ 𝑏. Với bất kỳ phương án
nào mà 𝑎 và 𝑏 chênh lệch nhau nhiều hơn 1 đơn vị, ta có thể tăng 𝑎 lên 1 và giảm
𝑏 đi 1, điều này làm cho chu vi không đổi nhưng diện tích được tăng lên:
(𝑎 + 1) × (𝑏 − 1) = 𝑎𝑏 + ⏟
𝑏 − 𝑎 − 1 > 𝑎𝑏
>0
Suy ra tồn tại phương án thỏa mãn yêu cầu mà 𝑎 và 𝑏 chênh lệch nhau không quá
1, ta sẽ tìm một phương án như vậy.
Nhận xét thứ hai là 𝑏 ≥ ⌈√𝑛⌉, thật vậy, nếu 𝑏 < ⌈√𝑛⌉ thì do 𝑏 là số nguyên, ta có
𝑏 < √𝑛 và vì thế 𝑎 × 𝑏 ≤ 𝑏 2 < 𝑛, trái với ràng buộc.
Ta cũng suy ra luôn được 𝑎 ≤ ⌈√𝑛⌉, bởi khi 𝑏 ≥ ⌈√𝑛⌉, chọn 𝑎 = ⌈√𝑛⌉ cũng đủ để
𝑎 × 𝑏 ≥ 𝑛 rồi, tăng 𝑎 lên nữa sẽ không tối ưu về chu vi. Vậy thì:
𝑎 ≤ ⌈√𝑛⌉ ≤ 𝑏
118
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
Ban đầu, chương trình được viết để thể hiện thuật toán với các bước tổng thể,
mỗi bước nêu lên một công việc phải thực hiện.
Một công việc đơn giản hoặc là một đoạn chương trình đã quen thuộc thì ta tiến
hành viết mã lệnh ngay bằng ngôn ngữ lập trình.
Một công việc phức tạp thì ta lại chia ra thành những công việc nhỏ hơn để lại
tiếp tục với những công việc nhỏ hơn đó.
Phương pháp tinh chế từng bước là một thể hiện của tư duy giải quyết vấn đề từ
trên xuống, giúp cho người lập trình có được một định hướng thể hiện trong phong
cách viết chương trình. Tránh việc mò mẫm, xóa đi viết lại nhiều lần, biến chương
trình thành tờ giấy nháp.
Ví dụ: Viết chương trình cho nhập vào một số nguyên dương 𝑘 và liệt kê các số
nguyên tố trong phạm vi từ 1 tới 𝑘.
Thuật toán ta xây dựng là với mỗi giá trị 𝑛 chạy từ 2 tới 𝑘, ta kiểm tra nếu 𝑛 là số
nguyên tố thì in ra 𝑛. Chương trình đầu tiên được viết thô như sau:
1 | #include <iostream>
2 | using namespace std;
3 |
4 | int main ()
5 | {
6 | int k;
7 | cout << "Cho k = "; cin >> k;
8 | for (int n = 2; n <= k; ++n)
9 | {
10 | «Kiểm tra tính nguyên tố của n»;
11 | if («n là số nguyên tố»)
12 | cout << n << ' ';
13 | }
14 | }
Tiếp theo, việc kiểm tra tính nguyên tố là một đoạn chương trình đã quen thuộc,
ta cụ thể hóa luôn thành mã lệnh. Trong quá trình cụ thể hóa và thử nghiệm, ta có
thể thêm những thao tác nhỏ miễn là không ảnh hưởng tới thiết kế ban đầu. Chẳng
hạn ta có thể in ra 6 số nguyên tố trên một dòng màn hình và mỗi số chiếm 10
khoảng trống cho đẹp. Muốn vậy ta khởi tạo một biến đếm int cnt = 0; mỗi lần in
ra một số nguyên tố ta ++cnt; và nếu cnt chia hết cho 6 một dấu xuống dòng sẽ
được in ra.
120
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
122
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển
124
Lê Minh Hoàng
Chương 8
Các cấu trúc điều khiển