Hàm trong C (Functions)
- 02-11-2023
- Toanngo92
- 0 Comments
Mục lục
Giới thiệu
Một hàm là một đoạn chương trình tự chứa thực hiện một nhiệm vụ cụ thể, rõ ràng. Thực tế, chúng là các đoạn chương trình con nhỏ hơn, giúp giải quyết các vấn đề lớn hơn.
Sử dụng Hàm
Hàm thường được sử dụng trong ngôn ngữ lập trình C để thực hiện một loạt các chỉ thị. Tuy nhiên, cách sử dụng hàm không tương tự như vòng lặp. Vòng lặp chỉ có thể lặp lại một chuỗi các chỉ thị nếu mỗi vòng lặp ngay lập tức theo sau vòng lặp trước đó. Nhưng việc gọi một hàm dẫn đến việc thực hiện một loạt các chỉ thị tại bất kỳ điểm nào trong chương trình. Hàm có thể được gọi bao nhiêu lần tuỳ ý. Ví dụ, nếu một phần của mã trong một chương trình tính phần trăm của một số cụ thể, và sau này trong cùng chương trình, phép tính tương tự phải được thực hiện bằng các số khác, thay vì viết lại các chỉ thị đó, một hàm có thể được viết để tính phần trăm của bất kỳ số nào được cung cấp. Chương trình sau đó có thể nhảy đến hàm đó, thực hiện các phép tính (trong hàm) và quay lại vị trí từ đó nó được gọi. Khái niệm này sẽ trở nên rõ ràng hơn khi cuộc thảo luận tiến triển.
Một khía cạnh quan trọng khác là việc viết và hiểu hàm dễ dàng hơn. Các hàm đơn giản có thể được viết để thực hiện các nhiệm vụ cụ thể. Ngoài ra, việc sửa lỗi trong chương trình dễ dàng hơn bởi vì cấu trúc chương trình trở nên dễ đọc hơn do dạng đơn giản của nó. Mỗi hàm có thể được thử nghiệm cá nhân cho tất cả các đầu vào có thể, bao gồm cả dữ liệu hợp lệ và dữ liệu không hợp lệ. Chương trình chứa hàm cũng dễ bảo trì hơn vì bất kỳ sửa đổi cần thiết có thể bị giới hạn trong các hàm cụ thể trong chương trình. Hơn nữa, hàm không chỉ có thể được gọi từ các điểm khác nhau trong chương trình mà còn có thể được đặt trong một thư viện các hàm liên quan và được sử dụng bởi nhiều chương trình khác, giúp tiết kiệm thời gian viết mã.
Cấu Trúc Hàm
Cú pháp chung của một hàm trong ngôn ngữ lập trình C là:
data_type function_name (parameters)
{
function_body
}
Kiểu_dữ_liệu chỉ định kiểu dữ liệu của giá trị mà hàm sẽ trả về. Nếu kiểu không được chỉ định, thì hàm được giả định trả về một kết quả kiểu số nguyên. Các tham số được ngăn cách bằng dấu phẩy. Một cặp dấu ngoặc tròn trống phải theo sau tên hàm, ngay cả khi nó không chứa bất kỳ tham số nào. Các tham số xuất hiện trong dấu ngoặc tròn này cũng được gọi là tham số hình thức hoặc tham số hình thức. Thân hàm có thể bao gồm một hoặc nhiều câu lệnh. Một hàm nên trả về một giá trị và do đó ít nhất phải có một câu lệnh return trong hàm.
Tham số của một hàm
Trước khi bàn về tham số chi tiết, hãy xem xét ví dụ sau:
Ví dụ 1:
#include <stdio.h>
main()
{
int i;
for (i = 1; i <= 10; i++)
printf("\nBình phương của %d là %d", i, squarer(i));
}
int squarer(int x)
{
int j;
j = x * x;
return j;
}
Chương trình trên tính bình phương của các số từ 1 đến 10. Việc này được thực hiện bằng cách gọi hàm “squarer“. Dữ liệu được truyền từ phần gọi hàm (trong trường hợp trên là “main“) đến hàm được gọi “squarer” thông qua các đối số. Các đối số được gọi là đối số thực trong phần gọi hàm và là đối số hình thức trong phần định nghĩa hàm được gọi (squarer()). Kiểu dữ liệu của các đối số thực nên giống với kiểu dữ liệu của các đối số hình thức. Ngoài ra, số lượng và thứ tự của các đối số thực nên giống với các đối số hình thức.
Khi một hàm được gọi, quyền điều khiển được chuyển đến hàm được gọi, trong đó các đối số hình thức được thay thế bằng các đối số thực. Sau đó, hàm được thực hiện và khi gặp câu lệnh “return”, quyền điều khiển được chuyển trở lại chương trình gọi.
Hàm “squarer()” được gọi bằng cách truyền số cần tính bình phương. Đối số “x” có thể được khai báo một trong các cách sau khi định nghĩa hàm.
Phương pháp 1:
int squarer(int x)
{
/* x được định nghĩa cùng với kiểu dữ liệu của nó trong dấu ngoặc tròn */
// ... (thân hàm)
}
Phương pháp 2:
int squarer(x)
int x; // Kiểu dữ liệu của "x" được định nghĩa ngay sau tên hàm
{
// ... (thân hàm)
}
Lưu ý rằng trong trường hợp cuối cùng, “x” phải được định nghĩa ngay sau tên hàm, trước khối mã. Điều này hữu ích khi nhiều tham số cùng kiểu dữ liệu được truyền. Trong trường hợp như vậy, kiểu dữ liệu chỉ cần được đề cập một lần ở đầu.
Khi các đối số được khai báo trong ngoặc tròn, mỗi đối số phải được định nghĩa một cách riêng biệt, bất kể chúng có cùng kiểu dữ liệu hay không. Ví dụ, nếu “x” và “y” là hai đối số của hàm “abc()“, thì “abc(char x, char y)” là khai báo đúng và “abc(char x, y)” là sai.
Trả giá trị từ hàm
Câu lệnh return có hai mục đích:
- Nó ngay lập tức chuyển quyền điều khiển từ hàm trở lại chương trình gọi.
- Bất cứ điều gì nằm trong dấu ngoặc sau từ khóa return được trả về làm giá trị cho chương trình gọi.
Trong hàm squarer(), một biến j kiểu int được định nghĩa, lưu trữ bình phương của đối số được truyền vào. Giá trị của biến này được trả về cho chương trình gọi thông qua câu lệnh return. Một hàm có thể thực hiện một nhiệm vụ cụ thể và trả quyền điều khiển về chương trình gọi mà không cần trả về bất kỳ giá trị nào. Trong trường hợp đó, câu lệnh return có thể được viết là return (0) hoặc return. Lưu ý rằng nếu một hàm được xác định trả về một giá trị mà nó không trả về, nó sẽ trả về một giá trị rác (garbage value).
Trong chương trình tính bình phương của các số, chương trình truyền dữ liệu cho hàm squarer thông qua đối số. Có thể có các hàm mà không cần truyền bất kỳ đối số nào. Trong trường hợp này, hàm thực hiện một chuỗi câu lệnh và trả về giá trị, nếu cần.
Lưu ý rằng hàm squarer() cũng có thể được viết như sau:
int squarer(int x)
{
return (x * x);
}
Điều này xảy ra vì một biểu thức trong câu lệnh return được xem xét là hợp lệ giống như nó là một đối số. Trên thực tế, câu lệnh return có thể được sử dụng theo nhiều cách như sau:
return (hằng số): Trả về một giá trị hằng số, ví dụ: return (5);.
return (biến): Trả về giá trị của một biến, ví dụ: return (x);.
return (biểu thức): Trả về kết quả của một biểu thức, ví dụ: return (a + b);.
return (câu lệnh sau khi được đánh giá): Trả về kết quả của một câu lệnh sau khi nó đã được đánh giá, ví dụ: return (a > b ? a : b);.
Tuy nhiên, một giới hạn của câu lệnh return là nó chỉ có thể trả về một giá trị duy nhất, không thể trả về nhiều giá trị cùng một lúc.
Kiểu dữ liệu của một hàm
Type-specifier (kiểu dữ liệu) được sử dụng để chỉ định kiểu dữ liệu của đối số trả về của một hàm. Trong ví dụ được giải thích, type-specifier không được viết bên cạnh hàm squarer(), bởi vì squarer() trả về một giá trị kiểu int. Type-specifier không bắt buộc nếu một giá trị kiểu số nguyên được trả về hoặc nếu không có giá trị nào được trả về. Tuy nhiên, nó tốt hơn nếu bạn chỉ định kiểu dữ liệu nếu một giá trị số nguyên được trả về và tương tự, “void” nếu hàm không trả về giá trị gì.
Gọi một hàm
Một hàm có thể được gọi hoặc kích hoạt từ chương trình chính bằng cách sử dụng tên của nó, theo sau bởi dấu ngoặc đơn. Dấu ngoặc đơn là cần thiết để thông báo cho trình biên dịch rằng một hàm đang được đề cập. Khi tên hàm được sử dụng trong chương trình gọi, nó có thể là một phần của một câu lệnh hoặc một câu lệnh riêng lẻ. Do đó, câu lệnh luôn kết thúc bằng dấu chấm phẩy. Tuy nhiên, khi định nghĩa hàm, không sử dụng dấu chấm phẩy ở cuối. Sự thiếu vắng của dấu chấm phẩy chỉ ra cho trình biên dịch rằng một hàm đang được định nghĩa, chứ không phải là hàm đang được gọi.
Một số điểm cần nhớ:
- Dấu chấm phẩy được sử dụng ở cuối câu lệnh khi một hàm được gọi, nhưng không sau khi định nghĩa hàm.
- Dấu ngoặc đơn là bắt buộc sau tên hàm, bất kể hàm có đối số hay không.
- Hàm gọi một hàm khác được gọi là chương trình gọi hoặc hàm gọi, và hàm đang được gọi được gọi là chương trình được gọi hoặc hàm được gọi.
- Hàm không trả về giá trị số nguyên phải chỉ định kiểu giá trị đang trả về.
- Chỉ có thể trả về một giá trị bởi một hàm.
- Một chương trình có thể có một hoặc nhiều hàm.
Khai báo hàm
Một hàm nên được khai báo trong hàm main() trước khi nó được định nghĩa hoặc sử dụng. Điều này cần phải được thực hiện trong trường hợp hàm được gọi trước khi nó được định nghĩa.
Hãy xem đoạn mã sau:
#include <stdio.h>
main()
{
address();
// ...
}
address()
{
// ...
// ...
}
Trong ví dụ bạn đã cung cấp, hàm main() gọi hàm address(), và hàm address() được gọi trước khi nó được định nghĩa hoặc khai báo trong hàm main(). Điều này được gọi là “khai báo ngầm định” của một hàm. Một số trình biên dịch C có thể cho phép sử dụng hàm trước khi nó được khai báo hoặc định nghĩa một cách rõ ràng, nhưng điều này không nên làm.
Trong một số trường hợp, trình biên dịch có thể hiểu sai kiểu dữ liệu hoặc tham số của hàm, gây ra lỗi hoặc hành vi không mong muốn. Để viết mã an toàn và dễ đọc, nên luôn khai báo hoặc định nghĩa hàm trước khi sử dụng nó, thay vì dựa vào “khai báo ngầm định”.
Nguyên mẫu hàm
Một nguyên mẫu hàm là một phần khai báo hàm mà chỉ định kiểu dữ liệu của các đối số. Thông thường, hàm được khai báo bằng cách chỉ định kiểu giá trị mà hàm sẽ trả về và tên hàm. Tuy nhiên, tiêu chuẩn ANSI C cho phép khai báo số lượng và kiểu của các đối số của hàm. Một hàm abc() có hai đối số x và y kiểu int, và trả về kiểu char, có thể được khai báo như sau:
char abc();
hoặc
char abc(int x, int y);
Khai báo sau được gọi là nguyên mẫu hàm. Khi sử dụng nguyên mẫu hàm, C có thể tìm và báo cáo bất kỳ chuyển đổi kiểu không hợp pháp nào giữa các đối số được sử dụng để gọi hàm và định nghĩa kiểu của tham số của hàm. Một lỗi sẽ được báo cáo ngay cả khi có sự khác biệt giữa số lượng đối số được sử dụng để gọi hàm và định nghĩa hàm.
Cú pháp chung của một định nghĩa nguyên mẫu hàm là:
type function_name(type parm_name1, type parm_name2, ..., type parm_nameN);
Khi hàm được khai báo mà không có thông tin nguyên mẫu, trình biên dịch giả định rằng không có thông tin về các tham số. Một hàm không có đối số có thể bị hiểu nhầm là đã được khai báo mà không có nguyên mẫu. Để tránh điều này, khi một hàm không có đối số, nguyên mẫu của nó sử dụng void trong ngoặc đơn. Như đã nói trước đó, void mô tả rõ rằng hàm không trả về giá trị.
Ví dụ, nếu một hàm được gọi là noparam() trả về kiểu char và không có tham số, nó có thể được khai báo như sau:
char noparam(void);
Điều này cho biết rằng hàm không có tham số, và bất kỳ cuộc gọi nào đối với hàm đó mà truyền tham số vào hàm sẽ bị sai.
Khi một hàm không có prototype được gọi, tất cả các ký tự sẽ được chuyển đổi thành số nguyên và tất cả các số thực sẽ được chuyển đổi thành số thực kiểu double. Tuy nhiên, nếu một hàm có prototype, các kiểu được đề cập trong prototype sẽ được duy trì và không có việc chuyển đổi kiểu xảy ra.
Biến
Như đã thảo luận trước đó, biến là các vị trí được đặt tên trong bộ nhớ, được sử dụng để lưu giá trị có thể hoặc không thể bị thay đổi bởi một chương trình hoặc một hàm. Biến cơ bản có ba loại: biến cục bộ (local variables), tham số hình thức (formal parameters), và biến toàn cục (global variables).
- Biến cục bộ là những biến được khai báo bên trong một hàm.
- Tham số hình thức được khai báo trong định nghĩa hàm dưới dạng tham số.
- Biến toàn cục được khai báo bên ngoài tất cả các hàm.
Biến Cục Bộ
void blk1(void) {
char ch;
ch = 'a';
}
void blk2(void) {
char ch;
ch = 'b';
// ...
}
Biến ch được khai báo hai lần, một trong blk1() và một trong blk2(). Biến ch trong blk1() không có liên quan đến biến ch trong blk2(), vì mỗi biến ch chỉ được biết đến trong phạm vi mã mà nó được khai báo.
Vì các biến cục bộ được tạo và hủy trong khối mà chúng được khai báo, nên nội dung của chúng bị mất ngoài phạm vi khối. Điều này đồng nghĩa rằng chúng không thể duy trì giá trị của mình giữa các lần gọi hàm.
Mặc dù từ khóa auto có thể được sử dụng để khai báo biến cục bộ, nhưng hiếm khi được sử dụng vì tất cả các biến không toàn cục mặc định đều được coi là biến cục bộ.
Các biến cục bộ, được sử dụng bởi các hàm, thường được khai báo ngay sau dấu ngoặc nhọn mở của hàm và trước bất kỳ câu lệnh nào khác. Tuy nhiên, khai báo có thể được thực hiện bên trong một khối mã trong hàm. Ví dụ:
void blk1(void) {
int t;
t = 1;
if (t > 5) {
char ch;
// ...
}
// ...
}
Trong ví dụ trước đó, biến ch được tạo và chỉ tồn tại trong khối mã “if” (khối mã mà có điều kiện t > 5). Sau khi khối mã “if” kết thúc, biến ch không thể được tham chiếu hoặc sử dụng trong bất kỳ phần nào khác của hàm blk1. Điều này đúng như bạn đã mô tả.
Lợi ích quan trọng ở đây là rằng bộ nhớ chỉ được cấp phát cho biến ch nếu điều kiện để vào khối mã “if” được thỏa mãn. Nếu điều kiện này không được đáp ứng, không có bộ nhớ phụ thuộc vào biến ch sẽ được sử dụng. Điều này giúp tiết kiệm bộ nhớ và làm cho chương trình hiệu quả hơn, bởi vì bộ nhớ chỉ được cấp phát khi cần thiết và chỉ trong phạm vi cụ thể mà biến được sử dụng.
Lưu ý: Điểm quan trọng cần nhớ là tất cả các biến cục bộ phải được khai báo ở đầu của khối mà chúng được định nghĩa, trước bất kỳ câu lệnh thực thi nào.
Dòng mã sau có thể không hoạt động với hầu hết các trình biên dịch:
void blk1(void) {
int len;
len = 1;
char ch; /* Điều này sẽ gây lỗi */
ch = 'a';
// ...
}
Tham số Hình thức (Formal Parameters)
Một hàm sử dụng đối số (arguments) cần khai báo các biến để chấp nhận giá trị của các đối số này. Các biến này được gọi là tham số hình thức (formal parameters) của hàm và hoạt động giống như bất kỳ biến cục bộ nào trong một hàm.
Các tham số hình thức này được khai báo bên trong dấu ngoặc đơn theo sau tên của hàm. Hãy xem xét ví dụ sau:
void blk1(char ch, int i) {
if (i > 5) {
ch = 'a';
} else {
i = i + 1;
}
.
.
}
Hàm blk1() có hai tham số: ch và i.
Các tham số hình thức phải được khai báo kèm kiểu dữ liệu của chúng. Trong ví dụ trên, ch có kiểu dữ liệu char và i có kiểu dữ liệu int. Những biến này có thể được sử dụng bên trong hàm giống như các biến cục bộ thông thường. Chúng sẽ bị hủy khi thoát khỏi hàm.
Cần phải đảm bảo rằng các tham số hình thức được khai báo có cùng kiểu dữ liệu với các đối số (arguments) được sử dụng để gọi hàm. Trong trường hợp không khớp kiểu dữ liệu, ngôn ngữ lập trình C có thể không hiển thị lỗi nào, nhưng kết quả có thể bất ngờ. Điều này xảy ra vì ngôn ngữ C thường trả về một kết quả trong các trường hợp không thường. Người lập trình cần đảm bảo rằng không có lỗi không khớp kiểu dữ liệu xảy ra để đảm bảo tính đúng đắn của chương trình.
Tương tự như với biến cục bộ, bạn có thể gán giá trị cho các tham số hình thức của một hàm và chúng có thể được sử dụng trong bất kỳ biểu thức C hợp lệ nào. Tham số hình thức hoạt động giống như các biến cục bộ bình thường bên trong hàm, vì vậy bạn có thể thực hiện các phép gán và sử dụng chúng trong các biểu thức một cách thông thường.
Dưới đây là một ví dụ đơn giản:
Biến Toàn Cục (Global Variables)
int ctr; /* ctr is global */
void blk1(void);
void blk2(void);
void main(void)
{
ctr = 10;
blk1();
// ...
}
void blk1(void)
{
int rtc;
if (ctr > 8)
{
rtc = rtc + 1;
blk2();
}
}
void blk2(void)
{
int ctr;
ctr = 0;
}
Trong đoạn mã trên, ctr là một biến toàn cục và mặc dù nó được khai báo bên ngoài main() và blk1(), nó có thể được tham chiếu bên trong chúng. Tuy nhiên, biến etx trong blk2(), tuy cùng tên, lại là một biến cục bộ và không có mối liên hệ gì với biến toàn cục ctr. Nếu một biến toàn cục và một biến cục bộ có cùng tên, tất cả các tham chiếu đến tên đó bên trong phạm vi định nghĩa của biến cục bộ sẽ liên quan đến biến cục bộ và không phải biến toàn cục.
Bộ nhớ cho các biến toàn cục nằm trong một vùng bộ nhớ cố định. Biến toàn cục rất hữu ích khi nhiều hàm trong chương trình cần truy cập cùng một dữ liệu. Tuy nhiên, cần tránh sử dụng biến toàn cục một cách không cần thiết, chủ yếu vì chúng chiếm bộ nhớ trong suốt thời gian chương trình đang chạy. Ngoài ra, sử dụng biến toàn cục thay vì biến cục bộ khi chỉ cần biến cục bộ là đủ có thể làm cho hàm sử dụng nó trở nên ít linh hoạt. Điều này sẽ rõ qua đoạn mã chương trình sau:
int i, j;
int addgen(int i, int j)
{
return (i + j);
}
void addspe(void)
{
return (i + j);
}
Cả hai hàm addgen() và addspe() đều trả về tổng của hai biến 4 và 4. Tuy nhiên, hàm addgen() được sử dụng để trả về tổng của bất kỳ hai số nào, trong khi đó, hàm addspe() chỉ trả về tổng của hai biến toàn cục i và j.
Lưu ý rằng biến i và j ở đây là biến toàn cục, nên có thể được sử dụng trong bất kỳ hàm nào trong chương trình. Tuy nhiên, việc sử dụng biến toàn cục khi không cần thiết có thể làm cho mã nguồn trở nên khó bảo trì và hiểu, nên nó nên được sử dụng một cách cẩn thận và chỉ khi thật sự cần thiết.
Lớp Lưu Trữ (Storage Classes)
Mỗi biến trong ngôn ngữ lập trình C đều có một đặc điểm gọi là lớp lưu trữ (storage class). Lớp lưu trữ xác định hai khía cạnh của biến: tuổi thọ (lifetime) và tầm nhìn (visibility) của nó. Tuổi thọ của một biến là thời gian mà nó giữ giá trị cụ thể. Tầm nhìn của một biến xác định các phần của chương trình nào có thể nhìn thấy biến đó. Một biến có thể được nhìn thấy trong một khối mã, một hàm, một tệp, một nhóm các tệp hoặc toàn bộ chương trình.
Từ góc nhìn của trình biên dịch C, tên của một biến xác định một vị trí vật lý nào đó trong máy tính, nơi dãy bit đại diện cho giá trị của biến được lưu trữ. Có hai loại vị trí cơ bản trong máy tính mà giá trị này có thể được lưu trữ: trong bộ nhớ hoặc trong thanh ghi CPU. Lớp lưu trữ của biến quyết định xem biến nên được lưu trữ trong bộ nhớ hay trong một thanh ghi. Có bốn lớp lưu trữ trong ngôn ngữ C. Chúng là:
- automatic (tự động)
- external (ngoài)
- static (tĩnh)
- register (đăng ký)
Các từ khoá được in đậm chỉ ra cách chúng được sử dụng như một thông số lớp lưu trữ. Thông số lớp lưu trữ đứng đầu phần còn lại của khai báo biến. Cú pháp tổng quát của nó:
thông_số_lớp_lưu_trữ kiểu tên_biến;
Biến Tự Động (Automatic Variables)
Biến tự động không gì khác ngoài các biến cục bộ, đã được thảo luận trước đó. Tầm nhìn của biến tự động có thể nhỏ hơn toàn bộ hàm, nó được khai báo bên trong một câu lệnh kết hợp. Tầm nhìn sau đó bị giới hạn trong câu lệnh kết hợp đó. Chúng có thể được khai báo bằng từ khoá auto, mặc dù việc khai báo không bắt buộc. Bất kỳ biến nào được khai báo trong một hàm hoặc một khối mã mặc định thuộc lớp auto và hệ thống sẽ dành một khu vực bộ nhớ cần thiết cho biến đó.
Extern (Ngoài)
Trong ngôn ngữ C, một chương trình lớn có thể được chia thành các mô-đun nhỏ hơn, có thể được biên dịch một cách riêng rẽ và sau đó được liên kết lại với nhau. Điều này được thực hiện để tăng tốc quá trình biên dịch cho các dự án lớn. Tuy nhiên, khi các mô-đun được liên kết, tất cả các tệp phải biết về các biến toàn cục được yêu cầu bởi chương trình. Một biến toàn cục chỉ có thể được khai báo một lần. Nếu hai biến toàn cục có cùng tên được khai báo trong cùng một tệp, thông báo lỗi như “tên biến trùng lặp” có thể xuất hiện hoặc trình biên dịch C có thể đơn giản chọn một biến. Cùng vấn đề xảy ra nếu tất cả các biến toàn cục cần thiết bởi chương trình được bao gồm trong từng tệp. Mặc dù trình biên dịch không xuất thông báo lỗi nào trong quá trình biên dịch, thực tế là các bản sao của cùng một biến đang được tạo ra. Trong quá trình liên kết các tệp, trình liên kết sẽ hiển thị thông báo lỗi như “nhãn trùng lặp” vì nó không biết sử dụng biến nào.
Lớp lưu trữ extern được sử dụng trong trường hợp như vậy. Tất cả biến toàn cục được khai báo trong một tệp và các biến cùng tên được khai báo là extern trong tất cả các tệp khác. Xem xét đoạn mã sau:
File1:
int i, j;
char a;
main()
{
.
.
.
}
abc()
{
i = 123;
.
.
}
File2:
extern int i, j;
extern char a;
xyz()
{
i = j * 5;
.
.
}
pqr()
{
j = 50;
.
.
}
File2 có các biến toàn cục giống như File1, ngoại trừ việc các biến này đã có từ khoá extern được thêm vào khai báo của chúng. Từ khoá này cho biết cho trình biên dịch về kiểu và tên của các biến toàn cục được sử dụng mà không tạo thêm bộ nhớ cho chúng.
Khi hai mô-đun được liên kết, tất cả các tham chiếu đến các biến bên ngoài được giải quyết. Nếu một biến không được khai báo trong một hàm, trình biên dịch sẽ kiểm tra xem nó có khớp với bất kỳ biến toàn cục nào không. Nếu tìm thấy sự khớp, trình biên dịch giả định rằng đang tham chiếu đến một biến toàn cục.
Biến Tĩnh (Static Variables)
Biến tĩnh là các biến có tính cố định trong phạm vi của chính hàm hoặc tệp chứa chúng. Khác với biến toàn cục, chúng không được biết đến bên ngoài hàm hoặc tệp của họ, nhưng chúng giữ giá trị của mình qua các lần gọi hàm. Điều này có nghĩa là khi một hàm kết thúc và sau đó được gọi lại sau này, các biến tĩnh được định nghĩa trong hàm đó vẫn giữ giá trị của họ. Khai báo biến bắt đầu bằng cách sử dụng từ khoá static để xác định lớp lưu trữ.
Có thể định nghĩa biến tĩnh có cùng tên với các biến bên ngoài dẫn đầu. Biến cục bộ (kể cả biến tĩnh và biến tự động) có ưu tiên hơn các biến bên ngoài và giá trị của các biến bên ngoài sẽ không bị ảnh hưởng bởi bất kỳ thay đổi nào của các biến cục bộ. Các biến bên ngoài có cùng tên với các biến cục bộ trong một hàm không thể truy cập trực tiếp trong hàm đó.
Có thể gán giá trị ban đầu cho các biến trong các khai báo biến tĩnh, nhưng các giá trị này phải được biểu thức hoặc hằng số. Trình biên dịch tự động gán giá trị mặc định là không đến bất kỳ biến tĩnh nào chưa được khởi tạo. Khởi tạo diễn ra ở đầu chương trình. Xem xét hai chương trình sau. Sự khác biệt giữa biến cục bộ tự động và biến cục bộ tĩnh sẽ trở nên rõ ràng.
Biến Tự Động
Ví dụ 2:
#include <stdio.h>
void incre(); // Function declaration
int main()
{
incre();
incre();
incre();
return 0;
}
void incre()
{
static char var = 65; // var is a static variable
printf("\nThe character stored in var is %c", var++);
}
Đầu ra của chương trình trên sẽ là:
The character stored in var is A
The character stored in var is A
The character stored in var is A
Biến tĩnh
Ví dụ 3:
#include <stdio.h>
void incre(); // Function declaration
int main()
{
incre();
incre();
incre();
return 0;
}
void incre()
{
static char var = 65; // var is a static variable
printf("\nThe character stored in var is %c", var++);
}
Đầu ra của chương trình trên sẽ là:
The character stored in var is A
The character stored in var is B
The character stored in var is C
Cả hai chương trình đều gọi hàm inere() ba lần. Trong chương trình đầu tiên, mỗi lần inere() được gọi, biến var với lớp lưu trữ auto (lớp lưu trữ mặc định) lại được khởi tạo lại thành €5 (tương đương với mã ASCII của ký tự A). Do đó, khi hàm kết thúc, giá trị mới của var (66) bị mất (mã ASCII của ký tự B).
Trong chương trình thứ hai, biến var thuộc lớp lưu trữ tĩnh (static). Ở đây, var chỉ được khởi tạo thành 65 một lần sau khi chương trình được biên dịch. Ở cuối cuộc gọi hàm đầu tiên, var có giá trị 66 (ASCII của B) và tương tự trong cuộc gọi hàm tiếp theo, var có giá trị 67 (ASCII của C). Cuối cùng, khi cuộc gọi hàm cuối cùng kết thúc, var được tăng thêm trong quá trình thực thi của câu lệnh print(). Giá trị này sẽ bị mất khi chương trình kết thúc.
Biến thanh ghi (Register Variables)
Máy tính có các thanh ghi trong Đơn vị Logic Toán học (ALU), được sử dụng để tạm thời lưu trữ dữ liệu cần truy cập nhiều lần. Kết quả trung gian của các phép tính cũng được lưu trữ trong các thanh ghi. Các hoạt động thực hiện trên dữ liệu được lưu trữ trong các thanh ghi nhanh hơn so với dữ liệu được lưu trữ trong bộ nhớ. Trong ngôn ngữ hợp ngữ, một lập trình viên có quyền truy cập vào các thanh ghi này và có thể di chuyển dữ liệu được sử dụng thường xuyên vào chúng, từ đó làm cho chương trình chạy nhanh hơn. Một lập trình viên ở mức cao thường không có quyền truy cập vào các thanh ghi của máy tính. Trong C, lựa chọn nơi lưu trữ một giá trị đã được để lại cho lập trình viên. Nếu một giá trị cụ thể phải được sử dụng thường xuyên (ví dụ: giá trị điều khiển một vòng lặp), lớp lưu trữ của nó có thể được đặt tên là “register”. Sau đó, nếu trình biên dịch tìm thấy một thanh ghi trống và nếu các thanh ghi của máy đủ lớn để lưu trữ biến đó, thì biến đó sẽ được đặt vào thanh ghi đó. Ngược lại, trình biên dịch xem xét biến đăng ký như bất kỳ biến tự động nào, nghĩa là chúng được lưu trữ trong bộ nhớ. Từ khóa “register” phải được sử dụng để xác định các biến đăng ký.
Phạm vi và khởi tạo của các biến đăng ký giống như của các biến tự động, ngoại trừ vị trí lưu trữ. Biến đăng ký chỉ có tác dụng trong phạm vi một hàm cụ thể. Điều này có nghĩa rằng chúng xuất hiện khi hàm được gọi và giá trị bị mất sau khi hàm kết thúc. Khởi tạo của các biến này là trách nhiệm của lập trình viên.
Do số lượng thanh ghi có hạn chế, một lập trình viên phải xác định xem các biến được sử dụng trong chương trình được sử dụng lặp đi lặp lại và sau đó khai báo chúng là các biến đăng ký.
Ưu điểm của các biến đăng ký thay đổi từ máy tính này sang máy tính khác và từ trình biên dịch C này sang trình biên dịch C khác. Đôi khi, các biến đăng ký không được hỗ trợ hoàn toàn – từ khóa “register” được chấp nhận nhưng được xem xử lý giống từ khóa “auto”. Trong các trường hợp khác, nếu các biến đăng ký được hỗ trợ và lập trình viên sử dụng chúng một cách cẩn thận, chương trình có thể chạy nhanh gấp đôi.
Các biến đăng ký được khai báo như sau:
register int x;
register char c;
Khai báo “register” chỉ có thể áp dụng cho biến tự động (automatic variables) và đối số thức tế (formal arguments). Trong trường hợp sau, khai báo sẽ trông như sau:
void f(register int c, register int n) {
register int i;
// ...
}
Hãy xem xét một ví dụ, trong đó tổng của các lập phương của các chữ số của một số bằng chính số đó. Ví dụ, 370 là một số như vậy bởi vì:
33 + 73 + 0 = 27 + 343 + 0 = 370
Chương trình sau in ra tất cả các số như vậy trong khoảng từ 1 đến 999:
Ví dụ 4:
include <stdio.h>
int main() {
int i;
int no, digit, sum;
printf("\nThe numbers whose Sum of Cubes of Digits is Equal to the number itself are:\n\n");
for (i = 1; i < 1000; i++) {
no = i;
sum = 0;
while (no) {
digit = no % 10;
no = no / 10;
sum = sum + digit * digit * digit;
}
if (sum == i) {
printf("%d\n", i);
}
}
return 0;
}
Kết quả hiển thị ra sẽ là:
The numbers whose Sum of Cubes of Digits is Equal to the number itself are
1
153
370
371
407
Trong chương trình trên, giá trị của biến ‘s’ biến đổi từ 1 đến 999. Đối với mỗi giá trị trong khoảng này, bình phương của từng chữ số riêng lẻ được cộng lại và kết quả được so sánh. Nếu hai giá trị này bằng nhau, dấu ‘+’ sẽ được hiển thị.
Dấu ‘+’ được sử dụng để kiểm soát việc lặp (phần quan trọng nhất của chương trình). Biến này được khai báo có kiểu lưu trữ là ‘register’. Khai báo này giúp tăng hiệu suất của chương trình.
Gọi hàm
Nói chung, các hàm giao tiếp với nhau bằng cách truyền đối số. Đối số có thể được truyền theo một trong hai cách sau:
- Gọi theo giá trị
- Gọi theo tham chiếu
Gọi theo giá trị
Trong C, theo mặc định, tất cả đối số của hàm được truyền theo giá trị. Điều này có nghĩa rằng khi đối số được truyền vào hàm được gọi, các giá trị được truyền qua biến tạm thời. Tất cả các thay đổi được thực hiện trên các biến tạm thời này. Hàm được gọi không thể thay đổi giá trị của chúng. Xem xét ví dụ sau.
Ví dụ 5:
#include <stdio.h>
int adder(int a, int b);
int main() {
int a, b, c;
a = b = c = 0;
printf("\nEnter 1st integer: ");
scanf("%d", &a);
printf("Enter 2nd integer: ");
scanf("%d", &b);
c = adder(a, b);
printf("\n\na & b in main() are: %d, %d", a, b);
printf("\n\nc in main() is: %d\n", c); // c gives the addition of a and b
return 0;
}
int adder(int a, int b) {
int c;
c = a + b;
a *= a;
b += 5;
printf("\n\na & b within adder function are: %d, %d", a, b);
printf("\nc within adder function is: %d", c);
return c;
}
Kết quả mẫu cho đầu vào 2 và 4 sẽ là:
a & b in main() are: 2, 4
c in main() is: 6
a & b within adder function are: 4, 9
c within adder function is: 6
Chương trình trên chấp nhận hai số nguyên, sau đó truyền chúng vào hàm adder(). Hàm adder() thực hiện các bước sau: nó lấy hai số nguyên này làm đối số, cộng chúng lại, bình phương số nguyên đầu tiên, cộng 5 vào số nguyên thứ hai, in kết quả và trả lại tổng của các đối số thực tế. Các biến được sử dụng trong hàm main() và hàm adder() có cùng tên, tuy nhiên, không có gì khác là chung giữa họ. Chúng được lưu trữ tại các vị trí bộ nhớ khác nhau. Điều này rõ ràng từ kết quả của chương trình trên. Các biến a và b trong hàm adder() thay đổi từ 2 và 4 thành 4 và 9 tương ứng. Tuy nhiên, sự thay đổi này không ảnh hưởng đến giá trị của a và b trong hàm main(). Các biến này phải được lưu trữ tại các vị trí bộ nhớ khác nhau. Biến e trong hàm main() khác với biến c trong hàm adder().
Vì vậy, các đối số được gọi là được truyền bằng cách truyền giá trị khi giá trị của các biến được truyền cho hàm được gọi và bất kỳ sự thay đổi nào trên giá trị này không ảnh hưởng đến giá trị gốc của biến được truyền vào.
Gọi theo tham chiếu
Khi các đối số được truyền bằng cách gọi theo giá trị, giá trị của các đối số trong chương trình gọi không thay đổi. Tuy nhiên, có thể có các trường hợp nơi giá trị của các đối số phải được thay đổi. Trong trường hợp đó, gọi theo tham chiếu có thể được sử dụng. Trong gọi theo tham chiếu, hàm được cho phép truy cập vào các vị trí bộ nhớ thực tế của các đối số và do đó có thể thay đổi giá trị của các đối số trong chương trình gọi.
Ví dụ, xem xét hai hàm, mà lấy hai đối số, trao đổi giá trị của họ và trả về chúng. Nếu một chương trình như ví dụ dưới đây được viết cho mục đích này, nó sẽ không bao giờ hoạt động.
Ví dụ 6:
#include <stdio.h>
void swap(int *u, int *v) {
int temp = *u;
*u = *v;
*v = temp;
}
int main() {
int x, y;
x = 15;
y = 20;
printf("x = %d, y = %d\n", x, y);
swap(&x, &y);
printf("\nSau khi hoán đổi x = %d, y = %d\n", x, y);
return 0;
}
Kết quả trả ra sẽ như sau:
x= 15, y= 20
After interchanging x = 15, y = 20
Hàm swap() hoán đổi giá trị của u và w, nhưng các giá trị này không được truyền trở lại cho main(). Điều này xảy ra vì biến u và v trong hàm swap() khác biệt với biến x và y được sử dụng trong main(). Để đạt được kết quả mong muốn, bạn có thể sử dụng gọi theo tham chiếu, bởi vì nó sẽ thay đổi giá trị của các đối số thực tế. Con trỏ được sử dụng khi bạn muốn thực hiện gọi theo tham chiếu.
Con trỏ được truyền vào một hàm dưới dạng đối số để cho phép chương trình gọi truy cập các biến mà phạm vi của chúng không bao gồm hàm gọi. Khi một con trỏ được truyền vào một hàm, địa chỉ của một mục dữ liệu được truyền vào hàm, cho phép hàm truy cập tự do đến nội dung của địa chỉ đó từ bên trong hàm. Cả hàm và chương trình gọi nhận biết bất kỳ thay đổi nào được thực hiện trên nội dung của địa chỉ đó. Cách này cho phép đối số hàm cho phép sửa đổi các mục dữ liệu trong chương trình gọi, cho phép trao đổi dữ liệu hai chiều giữa chương trình gọi và hàm. Khi đối số của hàm là con trỏ hoặc mảng, thì gọi theo tham chiếu được sử dụng cho hàm, thay vì gọi theo giá trị cho đối số biến.
Các đối số hình thức của một hàm, là các con trỏ, được đặt trước bởi dấu sao (), giống như khai báo biến con trỏ, cho biết rằng chúng là con trỏ. Các đối số con trỏ thực tế trong lời gọi hàm phải được khai báo enthoi với dấu sao () hoặc là biến được trích dẫn (svar).
Ví dụ, định nghĩa hàm sau:
void getstr(char *ptr_str, int *ptr_int)
Cho biết rằng đối số ptr_str trỏ đến kiểu char và ptr_int trỏ đến kiểu int. Hàm này có thể được gọi bằng câu lệnh:
getstr(pstr, &var)
Ở đây, pstr được khai báo là một con trỏ và địa chỉ của biến var được truyền vào bằng cách gán giá trị thông qua,
*ptr_int = var;
hàm này bây giờ có thể gán giá trị cho biến vax trong chương trình gọi, cho phép truyền dữ liệu hai chiều
vào và ra khỏi hàm.
char *pstr;
Hãy xem ví dụ về hàm swap() giống như trong Ví dụ 7. Vấn đề này sẽ hoạt động khi con trỏ
được truyền thay vì các biến.
Ví dụ 7:
#include <stdio.h>
void swap(int *u, int *v);
int main()
{
int x, y, *px, *py;
/* Lưu trữ địa chỉ của x vào px */
px = &x;
/* Lưu trữ địa chỉ của y vào py */
py = &y;
x = 15;
y = 20;
printf("x = %d, y = %d\n", x, y);
swap(px, py);
/* Truyền địa chỉ của x và y */
printf("\n Sau khi hoán đổi x = %d, y = %d\n", x, y);
return 0;
}
void swap(int *u, int *v)
{
int temp;
temp = *u;
*u = *v;
*v = temp;
}
Kết quả của ví dụ trên sẽ là:
x= 15, y= 20
After interchanging x = 15, y = 20
Hai biến con trỏ px và py được khai báo, và địa chỉ của các biến x và y được gán cho chúng. Sau đó, các biến con trỏ này được truyền vào hàm swap(), mà hàm này sẽ hoán đổi các giá trị được lưu trữ trong x và y thông qua các con trỏ.
Gọi hàm lồng nhau (Nesting of Function Calls)
Gọi một hàm từ một hàm khác được gọi là việc lồng nhau của cuộc gọi hàm. Một chương trình kiểm tra xem một chuỗi có phải là chuỗi đối xứng (palindrome) hay không có thể được xem xét như một ví dụ về việc gọi hàm lồng nhau. Một chuỗi đối xứng là một chuỗi các ký tự mà khi đọc từ trái sang phải hoặc từ phải sang trái đều giống nhau. Hãy xem xét đoạn mã sau:
main()
{
.
.
palindrome();
.
.
}
palindrome()
{
.
.
getstr();
reverse();
cmp ();
.
.
}
Trong chương trình trên, hàm main() gọi hàm palindrome(). Hàm palindrome() sau đó gọi ba hàm khác là getstr(), reverse(), và cmp(). Hàm getstr() thu thập một chuỗi ký tự từ người dùng, hàm reverse() đảo ngược chuỗi đầu vào và hàm cmp() so sánh chuỗi đầu vào với chuỗi đã đảo ngược.
Bởi vì main() gọi palindrome(), và palindrome() gọi lần lượt getstr(), reverse(), và cmp(), việc gọi hàm lồng nhau được thực hiện để kiểm tra xem một chuỗi có phải là chuỗi đối xứng hay không.
Nhưng lưu ý rằng trong ngôn ngữ lập trình C, việc định nghĩa một hàm bên trong một hàm khác không được phép. Điều này có nghĩa là bạn không thể định nghĩa một hàm bên trong hàm palindrome() hoặc bất kỳ hàm nào khác trong ngôn ngữ C. Việc gọi hàm lồng nhau là hợp lệ, nhưng định nghĩa hàm bên trong một hàm không được hỗ trợ.
Chương trình có thể bao gồm nhiều tệp tin khác nhau. Các chương trình như vậy có thể sử dụng các hàm dài, trong đó mỗi hàm có thể nằm trong một tệp tin riêng. Tương tự như biến trong chương trình đa tệp, các hàm cũng có thể được định nghĩa là tĩnh (static) hoặc ngoại (external). Phạm vi của hàm ngoại (external function) là toàn bộ các tệp tin trong chương trình và đây là lớp lưu trữ mặc định cho các hàm. Các hàm tĩnh chỉ được nhìn thấy trong tệp tin chương trình và không được nhìn thấy ngoài tệp tin chương trình. Đầu tiên của hàm sẽ có dạng như sau:
static fn _type fn_name (argument list)
or
extern fn_type fn_name (argument list)
Từ khóa “extern” là tùy chọn và nó là lớp lưu trữ mặc định.
Con trỏ hàm
Một tính năng trong ngôn ngữ C đầy mạnh mẽ nhưng cũng gây hiểu lầm là con trỏ hàm. Mặc dù hàm không phải là một biến, nó lại có một vị trí cụ thể trong bộ nhớ mà có thể gán cho một con trỏ. Địa chỉ của một hàm chính là điểm bắt đầu của hàm và con trỏ hàm có thể được sử dụng để gọi một hàm.
Để hiểu cách con trỏ hàm hoạt động, ta cần phải rõ ràng về cách một hàm được biên dịch và được gọi trong C. Khi mỗi hàm được biên dịch, mã nguồn được biến đổi thành mã đối tượng và điểm bắt đầu được xác định. Khi một cuộc gọi được thực hiện đến một hàm, một cuộc gọi bằng ngôn ngữ máy được thực hiện đến điểm bắt đầu này. Vì vậy, nếu con trỏ chứa địa chỉ của điểm bắt đầu của một hàm, nó có thể được sử dụng để gọi hàm đó.
Địa chỉ của một hàm có thể được lấy bằng cách sử dụng tên của hàm mà không có dấu ngoặc hoặc tham số. Chương trình sau đây sẽ minh họa khái niệm về con trỏ hàm.
Ví dụ 8:
#include <stdio.h>
#include <string.h>
void check(char *a, char *b, int (*cmp)());
main()
{
char s1[80];
char s2[80];
int (*p) ();
p = strcmp;
gets(s1);
gets(s2);
check(s1, s2, p);
}
void check(char *a, char *b, int (*cmp) ())
{
printf("Kiểm tra sự bằng nhau\n");
if (!(*cmp)(a, b))
printf("Bằng nhau");
else
printf("Không bằng nhau");
}
Hàm check() được gọi bằng cách truyền vào hai con trỏ ký tự và một con trỏ hàm. Bên trong check(), các đối số được khai báo là con trỏ ký tự và một con trỏ hàm. Lưu ý cách khai báo con trỏ hàm. Cú pháp tương tự có thể được sử dụng khi khai báo con trỏ hàm khác, bất kể kiểu trả về của hàm. Dấu ngoặc tròn xung quanh *cmp là cần thiết để trình biên dịch hiểu câu lệnh này đúng cách.