Hãy xem chương trình ví dụ sau đây:
#inc loại <iostream>
int main ()
{
std::cout << "The sum of 3 and 4 is:" << add (3, 4) << '\ n';
return 0;
}
int add (int x, int y)
{
return lại x + y;
}
Bạn sẽ mong đợi chương trình này tạo ra kết quả:
The sum of 3 and 4 is: 7
Nhưng trên thực tế, nó không biên dịch được chút nào! Visual Studio tạo ra lỗi biên dịch sau:
add.cpp(5) : error C3861: 'add': identifier not found
Lý do chương trình này không biên dịch được là vì trình biên dịch các file code theo tuần tự. Khi trình biên dịch chạy tới lệnh gọi hàm add trong hàm main, nó không biết hàm add là gì, bởi vì chúng ta đã định nghĩa add cho ở bên dưới hàm main. Điều đó tạo ra lỗi không tìm thấy định danh(không tìm thấy tên hàm add).
Các phiên bản cũ hơn của Visual Studio sẽ tạo ra một lỗi như sau:
add.cpp(9) : error C2365: 'add' : redefinition; previous definition was 'formerly unknown identifier'
Chương trình trên có phần sai, vì trình biên dịch cho rằng hàm add không từng được định nghĩa ở vị trí đầu tiên.
Khi giải quyết các lỗi biên dịch trong các chương trình của bạn thì luôn luôn giải quyết các lỗi đầu tiên được tạo trước khi giải quyết các lỗi sau đó, rồi biên dịch lại.
Để khắc phục vấn đề này, chúng ta cần phải giải quyết vấn đề là trình biên dịch không biết cái gì?. Có hai cách phổ biến để giải quyết vấn đề này.
Nội dung chính
1. Cách 1: Sắp xếp lại các lệnh gọi hàm
Một cách để giải quyết vấn đề này là sắp xếp lại các lệnh gọi hàm để hàm add được định nghĩa trước hàm main:
#include <iostream>
int add(int x, int y)
{
return x + y;
}
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
Bằng cách đó, vào thời điểm các gọi hàm add trong hàm main, trình biên dịch sẽ biết hàm add là gì. Bởi vì đây là một chương trình đơn giản nên việc sắp xếp này tương đối dễ thực hiện. Tuy nhiên, trong một chương trình lớn hơn, có thể rất khó khăn khi cố gắng tìm ra hàm nào gọi hàm nào (và theo thứ tự nào) để chúng có thể được khai báo tuần tự.
Hơn nữa, cách này không phải lúc nào cũng có thể thực hiện được. Chúng ta đã viết một chương trình có hai hàm A và B. Nếu hàm A gọi hàm B và hàm B gọi hàm A, thì khi đó không có cách nào để sắp xếp thứ tự các hàm theo cách để trình biên dịch hài lòng. Nếu bạn định nghĩa A trước, trình biên dịch sẽ phàn nàn rằng nó không biết B là gì. Nếu bạn định nghĩa B trước, trình biên dịch sẽ phàn nàn rằng nó không biết A là gì.
2. Cách 2: Khai báo trước khi dùng
Chúng ta cũng có thể khắc phục điều trên bằng cách khai báo trước.
Một khai báo trước cho phép chúng ta nói với trình biên dịch về sự tồn tại của một định danh(tên hàm or biến nào đó) trước khi thực sự sử dụng các định danh đó.
Trong trường hợp các hàm, điều này cho phép chúng ta báo cho trình biên dịch về sự tồn tại của một hàm trước khi chúng ta thự sự định nghĩa thân hàm cho nó. Theo cách này, khi trình biên dịch bắt gặp một lệnh gọi hàm, nó sẽ hiểu rằng chúng ta đang thực hiện gọi hàm và nó có thể kiểm tra hàm đó để đảm bảo chúng ta gọi hàm một cách chính xác, ngay cả khi nó không biết thân hàm là gì? hoặc hàm đó được định nghĩa ở đâu.
Để viết một khai báo trước cho một hàm, chúng ta sử dụng một câu lệnh khai báo được gọi là một hàm nguyên mẫu(function prototype). Hàm nguyên mẫu bao gồm kiểu trả về của hàm, tên, tham số, nhưng không có thân hàm (dấu ngoặc nhọn và mọi thứ ở giữa chúng), được kết thúc bằng dấu chấm phẩy.
Ở đây, một hàm nguyên mẫu cho hàm add:
int add(int x, int y); // function prototype includes return type, name, parameters, and semicolon. No function body!
Bây giờ, ở đây, chương trình ban đầu của chúng ta đã không biên dịch, sử dụng một hàm nguyên mẫu để khai báo trước cho hàm add:
#include <iostream>
int add(int x, int y); // forward declaration of add() (using a function prototype)
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // this works because we forward declared add() above
return 0;
}
int add(int x, int y) // even though the body of add() isn't defined until here
{
return x + y;
}
Bây giờ khi trình biên dịch gọi đến lệnh gọi hàm add trong hàm main, nó sẽ biết hàm add đó trông như thế nào (một hàm lấy hai tham số nguyên và trả về một số nguyên).
Điều đáng chú ý là các hàm nguyên mẫu không cần chỉ định tên của các tham số. Trong đoạn code trên, bạn cũng có thể khai báo trước một hàm như thế này:
int add(int, int); // valid function prototype
Tuy nhiên, chúng ta thích đặt tên cho các tham số của chúng ta (sử dụng cùng tên với hàm thực tế), vì nó cho phép bạn hiểu các tham số của hàm chỉ bằng cách nhìn vào hàm nguyên mẫu. Nếu không, bạn sẽ phải khai báo tên cho tham số tại vị trí định nghĩa hàm.
3. Quên định nghĩa thân hàm
Các lập trình viên mới thường tự hỏi điều gì sẽ xảy ra nếu họ khai báo trước một hàm nhưng không định nghĩa nó.
Câu trả lời là còn phụ thuộc vào nhiều thứ. Nếu một khai báo trước được thực hiện, nhưng hàm không bao giờ được gọi, chương trình sẽ biên dịch và chạy tốt. Tuy nhiên, nếu một khai báo trước được thực hiện và hàm được gọi, nhưng chương trình không định nghĩa hàm, chương trình sẽ biên dịch ổn, nhưng trình liên kết sẽ phàn nàn rằng nó không liên kết được hàm này.
Hãy xem xét chương trình sau:
#include <iostream>
int add(int x, int y); // forward declaration of add() using function prototype
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
// note: No definition for function add
Trong chương trình này, chúng ta khai báo trước hàm add và chúng ta gọi hàm add, nhưng chúng ta không bao giờ định nghĩa hàm add ở bất cứ nơi nào. Khi chúng ta thử và biên dịch chương trình này, Visual Studio tạo ra thông báo sau:
Compiling...
add.cpp
Linking...
add.obj : error LNK2001: unresolved external symbol "int __cdecl add(int,int)" (?add@@YAHHH@Z)
add.exe : fatal error LNK1120: 1 unresolved externals
Như bạn có thể thấy, chương trình được biên dịch ổn, nhưng nó đã thất bại ở giai đoạn liên kết vì int add (int, int) không bao giờ được định nghĩa.
4. Các loại khai báo trước khác
Khai báo trước thường được sử dụng với các hàm. Tuy nhiên, khai báo trước cũng có thể được sử dụng với các định danh(tên biến, tên đối tượng…) khác trong C ++, chẳng hạn như các biến và các kiểu do người dùng định nghĩa. Các biến và các kiểu dữ liệu do người dùng định nghĩa có một cú pháp khác nhau để khai báo trước, vì vậy chúng ta sẽ trình bày các biến này trong các bài học tiếp theo.
5. Khai báo vs Định nghĩa(Declarations vs. definitions)
Trong C ++, bạn sẽ thường xuyên nghe thấy các từ khai báo và định nghĩa trong Google, thường được sử dụng thay thế cho nhau. Có ý nghĩa gì? Bây giờ bạn có đủ kiến thức để hiểu sự khác biệt giữa hai thèn này.
Một định nghĩa(definitions) là việc thực hiện (cho các hàm hoặc kiểu dữ liệu nào đó) hoặc khởi tạo (cho các biến) định danh. Dưới đây là một số ví dụ về định nghĩa hàm add:
int add(int x, int y) // implements function add()
{
int z{ x + y }; // instantiates variable z
return z;
}
Một định nghĩa để đáp ứng các liên kết khi trình liên kết chạy. Nếu bạn sử dụng khai báo một định danh mà không cung cấp định nghĩa thì trình liên kết sẽ báo lỗi.
Quy tắc của một định nghĩa (hay viết tắt là ODR) là quy tắc nổi tiếng trong C ++. ODR có ba phần:
- Trong một file nhất định, một hàm, đối tượng, kiểu hoặc mẫu chỉ có thể có một định nghĩa.
- Trong một chương trình nhất định, một đối tượng hoặc hàm bình thường chỉ có thể có một định nghĩa mà thoy. Sự khác biệt này được thực hiện bởi vì các chương trình có thể có nhiều hơn một file (chúng ta sẽ trình bày điều này trong bài học tiếp theo).
- Các kiểu dữ liệu, hàm mẫu, các biến và hàm được phép có các định nghĩa giống hệt nhau trong các file khác nhau.
Nếu Vi phạm phần 1 của quy tắc(ODR) trên sẽ khiến trình biên dịch đưa ra lỗi không định nghĩa. Vi phạm ODR phần 2 có thể sẽ khiến trình liên kết đưa ra lỗi định nghĩa. Vi phạm ODR phần 3 sẽ gây ra hành vi không xác định.
Dưới đây, một ví dụ về vi phạm phần 1:
int add(int x, int y)
{
return x + y;
}
int add(int x, int y) // violation of ODR, we've already defined function add
{
return x + y;
}
int main()
{
int x;
int x; // violation of ODR, we've already defined x
}
Vì chương trình trên vi phạm ODR phần 1, điều này khiến trình biên dịch Visual Studio đưa ra các lỗi biên dịch sau:
project3.cpp(9): error C2084: function 'int add(int,int)' already has a body project3.cpp(3): note: see previous definition of 'add' project3.cpp(16): error C2086: 'int x': redefinition project3.cpp(15): note: see declaration of 'x'
Một khai báo là một tuyên bố cho trình biên dịch biết về sự tồn tại của một định danh(tên hàm, biến…) và thông tin kiểu dữ liệu của nó. Dưới đây là một số ví dụ về khai báo:
int add(int x, int y); // tells the compiler about a function named "add" that takes two int parameters and returns an int. No body!
int x; // tells the compiler about an integer variable named x
Một khai báo là tất cả những gì cần thiết để trình biên dịch không phát sinh lỗi khi dùng một định danh nào đó. Đây là lý do tại sao chúng ta có thể sử dụng một khai báo trước để báo cho trình biên dịch biết về một code định danh thực sự được định nghĩa sau này.
Trong C ++, tất cả các định nghĩa cũng tương tự như khai báo. Có int x xuất hiện trong các ví dụ của chúng ta, nó có cả định nghĩa và khai báo. Vì int x là một định nghĩa, nên nó cũng là một khai báo. Trong hầu hết các trường hợp, một định nghĩa có thể đáp ứng được mục đích của chúng ta, vì nó thỏa mãn cả trình biên dịch và trình liên kết. Chúng ta chỉ cần cung cấp một khai báo rõ ràng trước khi muốn sử dụng một định danh đó là được.
Mặc dù đúng là tất cả các định nghĩa đều là khai báo, nhưng điều ngược lại là không đúng: tất cả các khai báo không phải là định nghĩa. Một ví dụ về điều này là hàm nguyên mẫu – nó thỏa mãn trình biên dịch, nhưng không thoãi mãn trình liên kết. Những khai báo mà không định nghĩa được gọi là khai báo thuần. Các kiểu khai báo thuần khác bao gồm khai báo trước cho các biến và khai báo kiểu (bạn sẽ gặp chúng trong các bài học tiếp, không cần phải lo lắng về chúng ngay bây giờ).
ODR không áp dụng cho các khai báo thuần (đó là quy tắc cho định nghĩa, không phải là một quy tắc cho khai báo), do đó bạn có thể có nhiều khai báo thuần cho một code định danh như bạn mong muốn.