Làm quen với cách viết chương trình C++ trên nhiều file

Chào các bạn đang theo dõi khóa học lập trình trực tuyến ngôn ngữ C++.

Bấy lâu nay, trong suốt khóa học này, chúng ta chỉ viết những đoạn mã, những hàm đơn giản, ngắn gọn, và đặt chúng trong cùng một file chứa hàm main; những hàm được khai báo và định nghĩa ở bên trên hàm main, và được truy xuất đến để sử dụng trong hàm main. Nhưng vấn đề nảy sinh khi chương trình bắt đầu lớn dần lên, chức năng bắt đầu phức tạp hơn, nhiều người tham gia vào dự án hơn… Đây là thời điểm để chia nhỏ các chương trình con bên trong chương trình chính ra làm nhiều file.

Chia nhỏ chương trình lớn thành nhiều file

Việc đưa thêm file vào chương trình chính khá đơn giản khi ngôn ngữ C++ cho phép, và IDE hỗ trợ.

Trong Visual Studio (hiện tại mình đã update lên Visual Studio 2017), các bạn click chuột phải vào project hiện tại bạn đang làm việc, chọn Add -> New Item:

Chọn kiểu file .cpp sẽ là file chứa mã nguồn của chương trình con:

Tại đây, mình đặt tên cho file mình cần thêm vào là add.cpp, dự định nó sẽ chứa hàm tính tổng trong đó.

Nếu các bạn đã có sẵn file mình mong muốn, thay vì chọn Add -> New Item, các bạn có thể chọn Add -> Existing Item.

File mới thêm vào sẽ trở thành một phần của dự án, và khi biên dịch, compiler sẽ tìm, biên dịch, và liên kết file đó vào chương trình chính.

Bây giờ, mình muốn viết một hàm tính tổng đơn giản nằm tách biệt trong file add.cpp này như sau:

int add(int x, int y)
{
	return x + y;
}

Và mình muốn sử dụng hàm int add(int, int) này trong hàm main thuộc file main.cpp:

#include <iostream>

int main()
{
	std::cout << "Sum of 3 and 4 is: " << add(3, 4) << std::endl;
	return 0;
}

Các bạn thử tự compile chương trình trên xem kết quả như thế nào nha. Ở máy mình, compiler đưa ra thông báo lỗi không tìm thấy phần khai báo của hàm add.

Mình thử mở thư mục Debug, nơi compiler sản sinh ra những file object (.obj), thì thấy file add.cpp đã được biên dịch thành file add.obj rồi:

Nguyên nhân là khi compiler biên dịch một file mã nguồn, nó không biết thêm thông tin gì về những file mã nguồn khác, cũng không lưu lại thông tin gì về những file mã nguồn đã biên dịch trước đó. Trong trường hợp này, khi biên dịch đến file main.cpp, compiler không hề nhớ nó đã biên dịch hàm add trong file add.cpp, nên nó phàn nàn rằng không thấy định danh hàm add ở đâu cả.

Để giải quyết vấn đề này, một cách đơn giản là chúng ta đặt phần khai báo hàm add trong cùng file có gọi đến nó:

#include <iostream>

int add(int, int);

int main()
{
	std::cout << "Sum of 3 and 4 is: " << add(3, 4) << std::endl;
	return 0;
}

Bây giờ, khi compiler biên dịch đến file main.cpp, nó hiểu rằng có một hàm tên add đã được khai báo, và nó sẽ đi tìm phần định nghĩa cho hàm đó trong file khác.

Trong những lần đầu tiên làm việc với nhiều file trong một chương trình lớn, sẽ có không ít những trường hợp bị lỗi khi biên dịch, hoặc khi liên kết các file đã biên dịch. Một trong những lỗi thường gặp là:

Error	LNK2019	unresolved external symbol "int __cdecl add(int,int)" (?add@@YAHHH@Z) referenced in function _main	CPP_daynhauhoc_examples	E:\Projects\CPP_daynhauhoc_examples\main.obj	

Lỗi này xuất hiện khi chúng ta đã có khai báo hàm add, nhưng quên chưa viết phần định nghĩa cho nó. Trong ví dụ trên, các bạn thử xóa đi phần định nghĩa hàm add trong file add.cpp để xuất hiện lỗi như trên.

Thêm một lỗi có khả năng các bạn sẽ gặp là việc tái định nghĩa hàm:

Error	LNK1169	one or more multiply defined symbols found	CPP_daynhauhoc_examples	E:\Projects\CPP_daynhauhoc_examples\Debug\CPP_daynhauhoc_examples.exe

Các bạn thử thêm vào chương trình một file khác, ví dụ: sub.cpp. Trong file này, các bạn định nghĩa lại hàm int add(int, int) một lần nữa. Chương trình sẽ biên dịch bình thường, vì nó biên dịch từng file riêng rẽ, không hề biết đến sự tồn tại của hàm add trong file add.cpp. Nhưng khi đến quá trình link các file con vào trong chương trình chính, sẽ xuất hiện lỗi như trên.

==================================================

Khi biên dịch chương trình có chứa nhiều file, compiler có thể biên dịch theo một trình tự bất kỳ. Hơn nữa, nó biên dịch từng file riêng rẽ, không hề biết đến thông tin gì từ những file khác.

Khi học đến phần lập trình hướng đối tượng, các bạn có thể sẽ thường xuyên phải làm việc trên chương trình có nhiều file. Nên đây là thời điểm để các bạn bắt đầu tập làm quen với việc tổ chức mã nguồn chia thành nhiều file đơn giản hơn.

Header files

Ở phần trên, chúng ta đã biết cách chia nhỏ chương trình bằng cách đặt những phần định nghĩa hàm vào những file riêng biệt. Tuy nhiên, có nên đặt hết tất cả những khai báo hàm tại cùng một nơi không? Vậy, nếu đặt những khai báo hàm tại một nơi khác phù hợp hơn, thì chúng ta nên đặt ở đâu?

Các bạn chắc hẳn đã quen với phần mở rộng của file có tên .cpp đại diện cho file chứa mã nguồn C++. Một kiểu file khác cũng được sử dụng trong ngôn ngữ C/C++ là .h đại diện cho header files, đôi khi các bạn cũng thấy header files có đuôi .hpp nữa. Header files thường được sử dụng để chứa các khai báo hàm, khai báo struct/class…

Sử dụng built-in header files trong ngôn ngữ C++

Mình có một ví dụ đơn giản như sau:

#include <iostream>
int main()
{
	std::cout << "Hello World!" << std::endl;
	return 0;
}

Chương trình này in dòng chữ “Hello World!” ra màn hình sử dụng cout. Tuy nhiên, chương trình này chưa từng định nghĩa cout. Vậy, làm thế nào compiler hiểu được cout là gì?

Câu trả lời là cout đã được định nghĩa sẵn trong hearder file có tên iostream. Khi chúng ta sử dụng lệnh #include , chúng ta đang yêu cầu copy nội dung có trong hearder file tên là iostream vào chương trình.

Cần lưu ý rằng header files chỉ nên chứa phần khai báo, không nên chứa phần định nghĩa. Vậy, nếu cout được khai báo trong iostream, nó được định nghĩa ở đâu? Nó được định nghĩa trong các thư viện gọi là run-time support library, được compiler tìm và liên kết vào chương trình chính trong quá trình link object files.

Tự viết header files luôn

Bây giờ, chúng ta cùng quay trở lại với ví dụ ở phần đầu tiên trong bài học này. Với project chứa file main.cppadd.cpp.

Trong file add.cpp, chúng ta đã có định nghĩa:

int add(int x, int y)
{
    return x + y;
}

Và trong file main.cpp chúng ta có:

#include <iostream>
#include "add.h"

int main()
{
    std::cout << "The sum of 3 and 4 is " << add(3, 4) << std::endl;
    return 0;
}

Lần này, chúng ta tạo thêm file tiêu đề có tên add.h sẽ chứa phần khai báo của hàm add (là hàm đã được định nghĩa trong file add.cpp).

Và bây giờ, trong file add.h mình chỉ cần đơn giản viết phần khai báo hàm add vào đó:

int add(int x, int y); // function prototype for add.h -- don't forget the semicolon!

Với ví dụ đơn giản này, khi biên dịch chương trình, nội dung file “add.h” sẽ được compiler copy vào mã nguồn file main.cpp (vì trong file main.cpp chúng ta có include file “add.h”). Cách này có hiệu quả tương tự việc khai báo hàm add ngay trong file main.cpp, nhưng rõ ràng hơn về khía cạnh tổ chức mã nguồn.

Dưới đây là hình ảnh mô tả quá trình biên dịch và liên kết các file header (.h) và file code (.cpp)

================================================

Hi vọng qua bài học này, các bạn sẽ tập làm quen với việc tổ chức chương trình kết hợp nhiều file nhỏ hơn, giúp chương trình rõ ràng, và sẵn sàng để làm việc với team trong những dự án lớn hơn.

Hẹn gặp lại các bạn trong bài học tiếp theo trong khóa học lập trình C++ hướng thực hành.

Mọi ý kiến đóng góp hoặc thắc mắc có thể đặt câu hỏi trực tiếp tại diễn đàn.

www.daynhauhoc.com

8 Likes

Rất chi tiết và dễ hiểu, cảm ơn anh nhiều

Hi all.
Theo mình nhắc đến chia file mã nguồn trong C/C++ không thể không nhắc đến chỉ thị tiền biên dịch https://www.stdio.vn/articles/chi-thi-tien-xu-ly-trong-cc-512. Hiểu đơn giản trước khi được chuyển thành tệp chạy (exe) thì các tệp mã nguồn (tệp chứa ký tự chữ và số) được xào nấu qua 1 lần.
VD:

#define MAX 10 // Các chuỗi MAX 10 trong tệp mã nguồn được thay bằng số 10. (Không có ';' ở cuối #define).

#include cũng là một chỉ thị tiền biên dịch (Không nhầm là bắt đầu với #) nó nói cho trình biên dịch biết rằng hãy copy nội dung tệp được include vào tệp tin này. https://kipalog.com/posts/Thu-thuc-hien-4-Stage-khi-Compile-C-bang-GCC
??? Cái này thì có liên quan gì đến chia tệp tin mã nguồn ???
Trong thực tế khi bạn có 1 tệp tin mã nguồn A (thư viện gốc) được dùng trong các tệp tin B và C (Kế thừa khi thiết kế) và cuối cùng được dùng trong D (Kết tập khi sử dụng)

//File A
int MAX = 10;

//File B
#include "A"

//File C
#include "A"

//File D
#include "B"
#include "C"
#include "A" //Đôi khi

Khi đó trình tiền biên dịch sẽ gộp nội dung tập tin A vào B tạo ra B’, A vào C tạo ta C’ rồi gộp B’, C’ và D sinh ra D’ để biên dịch. Khi đó nội dung tệp tin A được lặp lại 2 lần trong D’ và báo lỗi int MAX = 10; được định nghĩa lại.error: redefinition of 'int MAX'.
??? Đến người phụ nữ còn không biết cha đứa bé là ai vậy thì làm sao để tránh việc 2 vợ chồng khác cha cùng ông nội ???
“muốn tháo chuông thì nhờ người buộc chuông
https://kilopad.com/Tieu-thuyet-c42/doc-sach-truc-tuyen-biet-tat-tat-chuyen-trong-thien-ha-b3639/chuong-288-tai-sao-noi-muon-thao-chuong-thi-nho-nguoi-buoc-chuong-ti288

//File A
#ifndef __A_HPP__ 
//Chỉ thị tiền biên dịch kiểm tra __A_HPP__ nếu chưa được định nghĩa thì thêm vào.
//Nếu đã được định nghĩa thì không làm gì cả.
#define __A_HPP__ //Định nghĩa __A_HPP__
int MAX = 10;
#endif //Kết thúc khối kiểm tra của tiền biên dịch.

//File B
#include "A"

//File C
#include "A"

//File D
#include "B"
#include "C"
#include "A" //Đôi khi

P/S

  1. Việc dùng tiền biên dịch để chống định nghĩa 2 lần không thực sự tối ưu.
  2. Đây chỉ là cách áp dụng tiền biên dịch vào giải quyết vấn đề trên nhưng thực tế trình tiền biên dịch còn có rất nhiều công dụng khác giúp tối ưu quá trình biên dịch tao ra file thực thi. Hi vọng ai đó có thể tìm hiểu sâu và chia sẻ cho mọi người.
5 Likes
83% thành viên diễn đàn không hỏi bài tập, còn bạn thì sao?