Xem thêm

2.11 - Tệp tiêu đề

Huy Erick
Đầu mối, và mục đích của chúng Trong quá trình lập trình, khi chương trình ngày càng lớn hơn (và sử dụng nhiều tệp tin), việc phải khai báo tiên đoán cho mỗi hàm mà...

Đầu mối, và mục đích của chúng Trong quá trình lập trình, khi chương trình ngày càng lớn hơn (và sử dụng nhiều tệp tin), việc phải khai báo tiên đoán cho mỗi hàm mà bạn muốn sử dụng, mà bị định nghĩa trong một tệp tin khác, trở nên ngày càng khó khăn. Liệu có tốt nếu bạn có thể đặt tất cả các tiên đoán của mình vào một nơi duy nhất và sau đó nhập chúng khi bạn cần chúng?

Các tệp tin mã C++ (với phần mở rộng .cpp) không phải là chỉ có các tệp tin thường thấy trong các chương trình C++. Loại tệp tin khác được gọi là "tệp tiêu đề". Tệp tiêu đề thường có phần mở rộng .h, nhưng đôi khi bạn cũng có thể thấy tệp tiêu đề với phần mở rộng .hpp hoặc không có phần mở rộng. Mục đích chính của tệp tiêu đề là lan truyền các khai báo đến các tệp mã (.cpp).

Sử dụng các tệp tiêu đề chuẩn của thư viện Xem xét chương trình sau đây:

#include   int main() {      std::cout << "Xin chào, thế giới!";     return 0;  }

Chương trình này in ra "Xin chào, thế giới!" trên màn hình bằng std::cout. Tuy nhiên, chương trình này không cung cấp định nghĩa hoặc khai báo cho std::cout, vậy làm sao trình biên dịch biết std::cout là gì?

Câu trả lời là std::cout đã được tiên đoán trước trong tệp tiêu đề "iostream". Khi chúng ta #include , chúng ta đang yêu cầu trình tiền xử lý sao chép toàn bộ nội dung (bao gồm cả những khai báo tiên đoán cho std::cout) từ tệp tin có tên "iostream" vào tệp tin đang thực hiện #include.

Hãy tưởng tượng xem điều gì sẽ xảy ra nếu tệp tin header "iostream" không tồn tại. Bất cứ khi nào bạn sử dụng std::cout, bạn sẽ phải gõ hoặc sao chép tất cả các khai báo liên quan đến std::cout vào đầu mỗi tệp tin sử dụng std::cout! Điều này đòi hỏi bạn phải biết rất rõ cách std::cout đã được khai báo, và công việc đó sẽ rất tốn công sức. Hơn nữa, nếu một hàm tiên đoán được thêm hoặc thay đổi, bạn sẽ phải cập nhật thủ công tất cả các khai báo tiên đoán.

Nhưng việc #include dễ dàng hơn nhiều!

Sử dụng tệp tiêu đề để lan truyền các khai báo tiên đoán Bây giờ hãy quay trở lại ví dụ chúng ta đã thảo luận trong bài học trước. Khi chúng ta kết thúc, chúng ta có hai tệp tin, add.cpp và main.cpp, có nội dung như sau:

add.cpp:

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

main.cpp:

#include   int add(int x, int y); // tiên đoán tiên tiến sử dụng hàm nguyên mẫu int main() {      std::cout << "Tổng của 3 và 4 là " << add(3, 4) << '\n';     return 0;  }

(If you’re recreating this example from scratch, don’t forget to add add.cpp to your project so it gets compiled in).

Trong ví dụ này, chúng ta đã sử dụng một khai báo tiên tiến để trình biên dịch biết add là gì khi biên dịch main.cpp. Như đã đề cập trước đó, việc thêm tiên đoán cho mỗi hàm mà bạn muốn sử dụng và tồn tại trong một tệp tin khác có thể trở nên rất tẻ nhạt nhanh chóng.

Hãy viết một tệp tiêu đề để giảm bớt gánh nặng đó. Viết một tệp tiêu đề thật dễ dàng, vì tệp tiêu đề chỉ bao gồm hai phần:

  1. Một "phần cản trước" của tệp tiêu đề, chúng ta sẽ thảo luận chi tiết hơn trong bài học tiếp theo.
  2. Nội dung thực tế của tệp tiêu đề, nơi chứa các khai báo tiên đoán cho tất cả các nhận dạng mà chúng ta muốn các tệp tin khác có thể nhìn thấy.

Thêm một tệp tiêu đề vào dự án hoạt động giống như việc thêm một tệp mã nguồn (được t covered in lesson 2.8 - Programs with multiple code files).

Nếu bạn sử dụng một IDE, hãy thực hiện các bước giống như bình thường và chọn "Header" thay vì "Source" khi yêu cầu. Tệp tiêu đề sẽ xuất hiện trong dự án của bạn.

Nếu bạn sử dụng dòng lệnh, chỉ cần tạo một tệp tin mới trong trình chỉnh sửa yêu thích của bạn trong cùng thư mục với các tệp tin nguồn (.cpp). Khác với các tệp tin mã, tệp tiêu đề không nên được thêm vào lệnh biên dịch của bạn (chúng được bao hàm mặc định bởi các câu lệnh "#include" và được biên dịch như một phần của các tệp tin mã nguồn của bạn).

Tệp tiêu đề thường được ghép cặp với các tệp mã, trong đó tệp tiêu đề cung cấp các tiên đoán tiên tiến cho các tệp mã tương ứng. Vì tệp tiêu đề của chúng ta sẽ chứa một tiên đoán cho các hàm được định nghĩa trong add.cpp, chúng ta sẽ gọi tệp tiêu đề mới của chúng ta là add.h.

Dưới đây là tệp tiêu đề đã hoàn chỉnh của chúng ta:

add.h:

// 1) Chúng ta thực sự nên có một phần cản trước ở đây, nhưng chúng tôi sẽ bỏ qua nó để đơn giản (chúng tôi sẽ bàn về phần cản trước trong bài học tiếp theo) // 2) Đây là nội dung của tệp .h, nơi chúng ta đặt các tiên đoán cho tệp tin .h khác nhau int add(int x, int y); // mẫu hàm cho add.h - đừng quên dấu chấm phẩy!

Để sử dụng tệp tiêu đề này trong main.cpp, chúng ta phải #include nó (sử dụng dấu ngoặc kép, không phải dấu ngoặc nhọn).

main.cpp:

#include "add.h" // Chèn nội dung của add.h tại điểm này. Chú ý sử dụng dấu ngoặc kép ở đây. #include   int main() {      std::cout << "Tổng của 3 và 4 là " << add(3, 4) << '\n';     return 0;  } 

add.cpp:

#include "add.h" // Chèn nội dung của add.h ở điểm này. Chú ý sử dụng dấu ngoặc kép ở đây.  int add(int x, int y) {      return x + y;  }

Khi trình tiền xử lý xử lý câu lệnh #include "add.h", nó sẽ sao chép nội dung của add.h vào tệp tin hiện tại ở điểm đó. Bởi vì add.h của chúng ta chứa một tiên đoán tiên tiến cho hàm add(), tiên đoán tiên được sao chép vào main.cpp. Kết quả cuối cùng là một chương trình tương tự chương trình mà chúng ta đã thêm tiên đoán tiên tại đầu main.cpp.

Do đó, chương trình của chúng ta sẽ biên dịch và liên kết đúng.

Lưu ý: Trong đồ họa phía trên, "Thư viện thời gian chạy tiêu chuẩn" nên được ghi là "Thư viện tiêu chuẩn C++".

Cách chèn định nghĩa vào tệp tiêu đề dẫn đến vi phạm quy tắc định nghĩa duy nhất Ở hiện tại, bạn nên tránh đặt định nghĩa hàm hoặc biến trong tệp tiêu đề. Làm như vậy thông thường sẽ dẫn đến vi phạm quy tắc định nghĩa duy nhất (ODR) trong những trường hợp tệp tiêu đề được bao gồm vào nhiều hơn một tệp mã nguồn.

Hãy thử minh họa điều này:

add.h:

// Chúng ta thực sự nên có một phần cản trước ở đây, nhưng chúng tôi sẽ bỏ qua nó để đơn giản (chúng tôi sẽ bàn về phần cản trước trong bài học tiếp theo) // định nghĩa cho add() trong tệp tiêu đề - đừng làm như vậy! int add(int x, int y) {      return x + y;  } 

main.cpp:

#include "add.h" // Nội dung của add.h sẽ được chèn vào đây #include   int main() {      std::cout << "Tổng của 3 và 4 là " << add(3, 4) << '\n';     return 0;  } 

add.cpp:

#include "add.h" // Nội dung của add.h sẽ được chèn vào đây 

Khi main.cpp được biên dịch, #include "add.h" sẽ được thay thế bằng nội dung của add.h và sau đó biên dịch. Do đó, trình biên dịch sẽ biên dịch cái gì đó giống như sau:

main.cpp (sau tiền xử lý):

// từ add.h: int add(int x, int y) {      return x + y;  }  // nội dung của iostream ở đây  int main() {      std::cout << "Tổng của 3 và 4 là " << add(3, 4) << '\n';     return 0;  } 

Điều này sẽ biên dịch một cách tốt. Khi trình biên dịch biên dịch add.cpp, #include "add.h" sẽ được thay thế bằng nội dung của add.h và sau đó biên dịch. Do đó, trình biên dịch sẽ biên dịch cái gì đó giống như sau:

add.cpp (sau tiền xử lý):

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

Điều này cũng sẽ biên dịch một cách tốt.

Cuối cùng, trình liên kết sẽ chạy. Trình liên kết sẽ nhìn thấy rằng có hai định nghĩa cho hàm add(): một trong main.cpp và một trong add.cpp. Đây là vi phạm của quy tắc ODR phần 2, nói rằng "Trong một chương trình cụ thể, một biến hoặc hàm bình thường chỉ có một định nghĩa."

Tệp tin mã nguồn nên bao gồm tệp tiêu đề tương ứng của chúng

Trong C++, có một quy tắc tốt cho các tệp mã nguồn để #include các tệp tiêu đề của chúng (nếu có). Trong ví dụ ở trên, add.cpp bao gồm add.h.

Điều này cho phép trình biên dịch phát hiện một số loại lỗi tại thời gian biên dịch, thay vì thời gian liên kết. Ví dụ:

something.h:

int something(int); // kiểu trả về của khai báo tiên đoán là int

something.cpp:

#include "something.h"  void something(int) // lỗi: kiểu trả về không đúng {  }

Vì something.cpp #include something.h, trình biên dịch sẽ nhận ra rằng hàm something() có kiểu trả về không khớp và cho chúng tôi một lỗi biên dịch. Nếu something.cpp không #include something.h, chúng ta sẽ phải đợi cho đến khi trình liên kết phát hiện sự không phù hợp, điều này lãng phí thời gian. Đối với ví dụ khác, xem nhận xét này.

Chúng ta cũng sẽ thấy nhiều ví dụ khác trong các bài học tiếp theo nơi nội dung yêu cầu bởi tệp mã nguồn được định nghĩa trong tệp tiêu đề đi kèm. Trong các trường hợp như vậy, việc chèn tệp tiêu đề là bắt buộc.

Không #include các tệp .cpp

Mặc dù trình tiền xử lý có thể dễ dàng làm điều này, thường bạn không nên #include các tệp .cpp. Các tệp này nên được thêm vào dự án và biên dịch.

Có một số lý do cho việc này:

  • Việc làm như vậy có thể gây xung đột tên giữa các tệp mã nguồn.
  • Trong một dự án lớn, rất khó để tránh những vấn đề quy tắc định nghĩa duy nhất (ODR).
  • Bất kỳ thay đổi nào đối với tệp .cpp như vậy sẽ làm cho tệp .cpp đó và bất kỳ tệp .cpp khác nào đã bao gồm nó biên dịch lại, điều này có thể mất nhiều thời gian. Tệp tiêu đề thay đổi ít thường xuyên hơn tệp mã nguồn.
  • Điều này không phổ biến.

Khắc phục sự cố

Nếu bạn nhận được một lỗi trình biên dịch cho biết add.h không được tìm thấy, hãy chắc chắn rằng tệp tin thực sự có tên add.h. Tùy thuộc vào cách bạn tạo và đặt tên nó, có thể tệp có tên như add (không có phần mở rộng) hoặc add.h.txt hoặc add.hpp. Hãy chắc chắn nó đang nằm trong cùng một thư mục với các tệp mã nguồn khác của bạn.

Nếu bạn nhận được một lỗi liên kết về hàm add không được định nghĩa, hãy chắc chắn rằng bạn đã bao gồm add.cpp trong dự án của bạn để định nghĩa cho hàm add có thể được liên kết vào chương trình.

Ngoặc kép vs ngoặc nhọn

Bạn có thể tò mò vì sao chúng ta sử dụng ngoặc nhọn cho iostream, và ngoặc kép cho add.h. Câu trả lời là tệp tiêu đề có cùng tên có thể tồn tại trong nhiều thư mục khác nhau. Cách chúng ta sử dụng ngoặc nhọn so với ngoặc kép sẽ giúp trình tiền xử lý có gợi ý về nơi nó nên tìm tệp tiêu đề.

Khi chúng ta sử dụng ngoặc nhọn, chúng ta đang nói với trình tiền xử lý rằng đây là một tệp tiêu đề mà chúng ta không viết. Trình tiền xử lý sẽ chỉ tìm kiếm tiêu đề trong các thư mục được chỉ định bởi các thư mục không gian. Các thư mục không gian được cấu hình là một phần của các thiết lập dự án / IDE / thiết lập trình viên của bạn, và thông thường mặc định là các thư mục chứa các tệp tiêu đề đi kèm với trình biên dịch và / hoặc hệ điều hành của bạn. Trình tiền xử lý sẽ không tìm kiếm tệp tiêu đề trong thư mục mã nguồn của dự án của bạn.

Khi chúng ta sử dụng ngoặc kép, chúng ta đang nói với trình tiền xử lý rằng đây là một tệp tiêu đề mà chúng ta đã viết. Trình tiền xử lý sẽ tìm tệp tiêu đề trước trong thư mục hiện tại. Nếu nó không tìm thấy tệp tiêu đề phù hợp, nó sẽ tìm trong các thư mục không gian.

Tại sao iostream không có phần mở rộng .h?

Một câu hỏi thường được đặt là "tại sao iostream (hoặc bất kỳ tệp tiêu đề thư viện tiêu chuẩn nào khác) không có phần mở rộng .h?". Câu trả lời là iostream.h là một tệp tiêu đề khác với iostream! Để giải thích đòi hỏi một bài học lịch sử ngắn.

Khi C++ được tạo ra, tất cả các tệp tin trong thư viện tiêu chuẩn đều kết thúc bằng hậu tố .h. Cuộc sống đã được chuẩn hóa và nó tốt đẹp. Phiên bản ban đầu của cout và cin được khai báo trong iostream.h. Khi ngôn ngữ được tiêu chuẩn hóa bởi ủy ban ANSI, họ quyết định chuyển tất cả các tên được sử dụng trong thư viện tiêu chuẩn vào không gian tên std để giúp tránh xung đột tên với các nhận dạng được khai báo bởi người dùng. Tuy nhiên, điều này gây ra một vấn đề: nếu các tên đó được chuyển vào không gian tên std, không có chương trình cũ (chứa #include ) nào sẽ hoạt động nữa!

Để giải quyết vấn đề này, một tệp tiêu đề mới không có phần mở rộng .h đã được giới thiệu. Các tệp tiêu đề mới này khai báo tất cả các tên bên trong không gian tên std. Điều này có nghĩa là các chương trình cũ (bao gồm #include ) sẽ không cần phải được viết lại, và các chương trình mới có thể #include .

Ngoài ra, nhiều thư viện còn lại được thừa hưởng từ C và vẫn hữu ích trong C++ đã được gán một tiền tố c (ví dụ: stdlib.h trở thành cstdlib).

Chèn các tệp tiêu đề từ các thư mục khác

Một câu hỏi thường gặp khác liên quan đến cách chèn các tệp tiêu đề từ các thư mục khác.

Một cách (không tốt) để làm điều này là bao gồm một đường dẫn tương đối đến tệp tiêu đề bạn muốn bao gồm là một phần của dòng #include. Ví dụ:

Mặc dù điều này sẽ biên dịch (giả sử các tệp tồn tại trong các thư mục tương ứng), mặt hạn chế của cách tiếp cận này là bạn phải phản ánh cấu trúc thư mục của mình trong mã của mình. Nếu bạn thay đổi cấu trúc thư mục của mình, mã của bạn sẽ không hoạt động nữa.

Một phương pháp tốt hơn là cho trình biên dịch hoặc IDE của bạn biết rằng bạn có một số tệp tiêu đề ở một vị trí khác, để trình biên dịch sẽ tìm kiếm chúng khi không thể tìm thấy chúng trong thư mục hiện tại. Điều này thường có thể được thực hiện bằng cách đặt một đường dẫn tiêu đề hoặc thư mục tìm kiếm trong cài đặt dự án / IDE của bạn.

Thứ tốt về phương pháp tiếp cận này là nếu bạn thay đổi cấu trúc thư mục của mình, bạn chỉ cần thay đổi một cài đặt trình biên dịch hoặc IDE duy nhất thay vì mỗi tệp mã.

Tệp tiêu đề có thể bao gồm các tệp tiêu đề khác

Thường xuyên một tệp tiêu đề sẽ cần một khai báo hoặc định nghĩa mà cư trú trong một tệp tiêu đề khác. Vì vậy, các tệp tiêu đề thường #include các tệp tiêu đề khác.

Khi tệp mã của bạn #includes tệp tiêu đề đầu tiên, bạn sẽ cũng nhận được bất kỳ tệp tiêu đề khác mà tệp đầu tiên bao gồm (và bất kỳ tệp tiêu đề nào bao gồm chúng, và tiếp tục với cách thức đó). Những tệp tiêu đề bổ sung này đôi khi được gọi là "tệp tiêu đề thông qua", vì chúng được bao gồm một cách ngầm, chứ không phải một cách rõ ràng.

Nội dung của các tệp tiêu đề thông qua này có sẵn để sử dụng trong tệp mã của bạn. Tuy nhiên, bạn thường không nên dựa vào nội dung của các tệp tiêu đề thông qua (trừ khi tài liệu tham khảo xác định rằng các tệp tiêu đề thông qua đó là bắt buộc). Việc triển khai các tệp tiêu đề có thể thay đổi theo thời gian hoặc khác nhau trên các hệ thống khác nhau. Nếu điều đó xảy ra, mã của bạn có thể chỉ biên dịch trên một số hệ thống hoặc mã chỉ biên dịch vào lúc này nhưng không phải trong tương lai. Điều này dễ dàng tránh được bằng cách bao gồm tất cả các tệp tiêu đề mà nội dung mã của bạn cần.

Rất không may là không có cách dễ dàng để phát hiện khi tệp mã của bạn đang dựa vào nội dung của một tệp tiêu đề thông qua mà không cần phải. Nhận thức về vấn đề này là rất quan trọng để tránh việc đưa ra quyết định thiết kế không đúng đắn.

Thứ tự #include của các tệp tiêu đề

Nếu các tệp tiêu đề của bạn được viết đúng và #include tất cả những gì chúng cần, thì thứ tự bao gồm không quan trọng.

Bây giờ hãy xem xét tình huống sau: giả sử tiêu đề A cần khai báo từ tiêu đề B, nhưng quên bao gồm nó. Trong tệp mã của chúng ta, nếu chúng ta bao gồm tiêu đề B trước tiêu đề A, mã của chúng ta vẫn sẽ biên dịch! Điều này bởi vì trình biên dịch sẽ biên dịch tất cả các khai báo từ B trước khi biên dịch mã từ A phụ thuộc vào những khai báo đó.

Tuy nhiên, nếu chúng ta bao gồm tiêu đề A trước tiêu đề B, thì trình biên dịch sẽ phàn nàn vì mã từ A sẽ được biên dịch trước khi trình biên dịch nhìn thấy các khai báo từ B. Điều này thực sự tốt hơn, vì lỗi đã được phát hiện và chúng tôi sau đó có thể sửa chúng.

Điều này có nghĩa rằng, nếu một trong các tiêu đề do người dùng không đúng bị thiếu một #include cho một thư viện bên thứ ba hoặc tiêu đề tiêu chuẩn, nó sẽ khái niệm một lỗi biên dịch để bạn có thể sửa chúng.

Phương pháp tiếp cận này giúp bạn tránh việc chương trình của bạn phụ thuộc vào nội dung của một tệp tiêu đề thông qua một cách không cố ý.

Các nguyên tắc tốt nhất cho tệp tiêu đề

Dưới đây là một số khuyến nghị khác cho việc tạo và sử dụng các tệp tiêu đề.

  • Luôn thêm phần cản trước của tệp tiêu đề (chúng ta sẽ bàn về phần này trong bài học tiếp theo).
  • Không định nghĩa biến và hàm trong tệp tiêu đề (cho đến bây giờ).
  • Đặt tên tệp tiêu đề theo tên tệp mã nguồn đi kèm của nó (ví dụ: grades.h được ghép cặp với grades.cpp).
  • Mỗi tệp tiêu đề nên có một nhiệm vụ cụ thể và độc lập nhất có thể. Ví dụ: bạn có thể đặt tất cả các khai báo liên quan đến chức năng A trong A.h và tất cả các khai báo liên quan đến chức năng B trong B.h. Điều này có nghĩa là nếu chỉ cần quan tâm đến chức năng A sau này, bạn chỉ cần #include A.h và không nhận được bất kỳ thứ gì liên quan đến B.
  • Hãy chú ý về những tiêu đề bạn phải bao gồm một cách rõ ràng cho nội dung mã của bạn yêu cầu, để tránh các tiêu đề thông qua ngầm đó. Cài đặt các tệp tiêu đề có thể thay đổi theo thời gian hoặc khác nhau trên các hệ thống khác nhau. Nếu điều đó xảy ra, mã của bạn có thể chỉ biên dịch trên một số hệ thống hoặc mã chỉ biên dịch vào lúc này nhưng không phải trong tương lai.
  • Rất không may là không có cách dễ dàng để phát hiện khi mã của bạn đang dựa vào các tiêu đề thông qua mà không cần phải. Nhận thức về vấn đề này là rất quan trọng để tránh việc đưa ra quyết định thiết kế không đúng đắn.

2.11 — Header files

1