Search…

Bản Chất Của Biến trong C/C++

02/11/202013 min read
Tìm hiểu về bản chất của biến trong C++.

Trong những buổi tiếp xúc đầu tiên với lập trình, có lẽ không còn ai xa lạ về từ “biến”. Biến xuất hiện ở trong bất kỳ ngôn ngữ lập trình nào, nó là cơ sở để xây dựng lên những đối tượng, thực thể khác to lớn hơn để dùng vào những mục đích khác nhau của chương trình. Bài viết này sẽ giúp cho người đọc đang bắt đầu học về lập trình căn bản sẽ hiểu rõ hơn, sâu hơn: “Thực chất thì chuyện gì đang xảy ra trong chương trình khi chúng ta thao tác với các kiểu biến?!”.

Ý tưởng hình thành

Có thể hiểu đơn giản rằng biến – là 1 khối dữ liệu có tên và kích thước được xác định. 1 cách chân thật và tự nhiên nhất có thể hiểu rằng biến giống như “1 cái hộp” và những gì nằm trong cái hộp đó sẽ quyết định giá trị của biến. Vậy khi cần định giá của chiếc hộp thì cần làm gì? Để làm điều đó cần mở nắp hộp ra và soi vào bên trong có những gì để cấu thành nên giá trị của chiếc hộp. Còn khi muốn gán giá trị cho chiếc hộp? Việc cần làm đó là đưa giá trị cần gán vào chiếc hộp đó là được rồi.

Biến trong thực tế

Thay vì nó là 1 cái hộp trong ý tưởng thì 1 biến được khai báo sẽ tương ứng với 1 phân khúc được xác định rõ ràng trong bộ nhớ. Những gì nằm trong phân khúc đó – nói 1 cách chính xác thì những binary code nằm trong phân khúc đó sẽ quyết định nên giá trị của biến. Khi chương trình cần biết giá trị của 1 biến thì nó phải đọc các binary code được lưu trong phân khúc ô nhớ lưu biến cần đánh giá, sau đó chuyển binary code đó thành giá trị thích hợp với kiểu biến và sử dụng chúng. Còn khi chương trình cần lưu trữ 1 giá trị vào biến thì việc cần làm đó chính là convert giá trị cần lưu trữ sang binary code, sau đó lưu trữ các binary code vào phân khúc của bộ nhớ đã được khởi tạo để lưu biến đó.

Đọc đến đây ắt hẳn đã phần nào hình dung nguyên lý của việc lưu trữ, gán và đọc dữ liệu của 1 biến. Nhưng bên cạnh đó cũng sẽ tồn tại 1 số vấn đề thường xảy ra trong quá trình tìm hiểu và thao tác với biến.

Những vấn đề cần làm sáng tỏ trước

Bộ nhớ chỉ lưu được binary code

Bộ nhớ của chỉ lưu được binary code, và đồng thời có nhiều kiêu biến khác nhau: int, char, float, short….. Chính vì vậy để đảm bảo sự rõ ràng giữa các kiểu biến thì mỗi biến khác nhau sẽ được convert sang binary code theo 1 kiểu khác nhau. Đó chính là nguyên nhân mà trình biên dịch khi làm việc với 1 kiểu dữ liệu cụ thể thì việc đầu tiên cần làm của trình biên dịch đó là thực hiện những tập lệnh để chuyển giá trị của kiểu biến đó thành 1 binary code thích hợp trước khi nó thực hiện những tập lệnh tiếp theo để lưu trữ binary code đó vào trong phân khúc bộ nhớ đã được cấp phát.

Vấn đề size của từng biến

Vấn đề này liên quan đến size của phân khúc bộ nhớ được cấp phát cho mỗi loại biến khác nhau. Trong quá trình học ắt hẳn cũng đều có nghe đến từ “địa chỉ của biến” (address of variable), đây là địa chỉ của ô nhớ nằm ở vị trí đầu tiên của phân khúc bộ nhớ được cấp phát để lưu biến đó. Nhưng chưa thể nào xác định được biến đó tốn bao nhiêu ô nhớ kể từ ô nhớ đầu tiên cho chương trình? Ý ở đây là chương trình làm sao để hiểu được sẽ cấp cho bao nhiêu ô nhớ khi cần 1 biến biểu diễn số nguyên chẳng hạn? Vì 1 byte vẫn có thể lưu được 1 biến số nguyên, 4 byte vẫn làm được điều đó và 1000 byte vẫn làm được điều đó. Để giải quyết vấn đề này thì mỗi hệ điều hành lại cho phép cấp phát dung lượng cho biến theo 1 kiểu khác nhau. Có thể hiểu rằng: "Mỗi loại biến là có 1 kích thước xác định (số byte – ô nhớ được cấp phát là nhất định) và bất biến ở mỗi hệ điều hành nhất định!".

Xét ví dụ sau

Biến char có dung lượng là 1 byte ở tất cả các hệ điều hành. Nhưng biến int thì không như thế! Ở hệ điều hành 16-bit thì biến int được phép chỉ tốn 2 byte – 2 ô nhớ, trong hệ điều hành 32-bit thì biến int được phép tốn 4 byte – 4 ô nhớ trong bộ nhớ và tương tự trong hệ điều hành 64-bit thì biến int được cấp 8 byte. Đến đây chắc sẽ có thắc mắc rằng: "Tại sao dùng hệ điều hành Windows x64 nhưng mà trong chương trình biến int chỉ có 4 byte?". Để trả lời cho việc này thì phải bắt nguồn từ thời điểm nhấn nút "New Project" trong IDE, vì lúc đó đã khởi tạo 1 project để chạy trên Windows x86 (project C/C++). Đến đây có lẽ đã hiểu tại sao các phần mềm dành cho hệ điều hành 32-bit thì chạy được trên 64-bit, nhưng mà ngược lại thì không được. Và toàn bộ ví dụ trong bài viết này sẽ nói đến những trường hợp trong hệ điều hành 32-bit.

Hình trên là ví dụ về biến int được cấp phát 4 byte với địa chỉ là 501 nằm trên bộ nhớ.

Chính vì mỗi biến là 1 size nhất định, và nó bất biến trên 1 hệ điều hành nên điều đó gây ra 1 số rắc rối và có rắc rối chắc chắn sẽ có hậu quả.

Rắc rối mang tên "size of variable"

Ý tưởng dẫn đến rắc rối

Tưởng tượng có 1 cái hộp cố định về kích thước – có nghĩa là nó không thể to thêm tý nào và nó cũng không thể nhỏ lại tý nào. Việc đó cũng không ảnh hưởng gì lắm nếu cất giữ những thứ có kích thước nhỏ hơn cái hộp vào trong đó. Nhưng nếu 1 cái gì đó kích thước to hơn cái hộp mà muốn cất nó vào trong hộp thì sao? Nếu cố nhồi nhét vào sẽ làm hỏng cái hộp mất.

Với phân khúc bộ nhớ được cấp phát cho biến cũng gặp trường hợp tương tự. Nhưng mà phân khúc bộ nhớ không thể bị phá hỏng khi cố nhồi nhét 1 lượng lớn binary code dài hơn binary code mà nó cho phép được. Và để giải quyết tình huống này, tiếp cận với 1 từ mới “overflow” – tràn. Tùy mỗi trường hợp sẽ có cách giải quyết cho việc tràn này, có loại sẽ tự động cắt bớt 1 số bit bị tràn ra bên ngoài để số bit còn lại vừa vặn với phân khúc được cấp phát, số khác thì sẽ không chấp nhận điều đó mà sẽ cho ra 1 thông báo lỗi (Error) hoặc sẽ đưa ra 1 ngoại lệ (Exception).

Hướng giải quyết cho overflow

Trong C/C++ hỗ trợ giải quyết nhưng với mỗi trường hợp thì 1 cách khác nhau.

Nếu trường hợp xảy ra tràn do việc khai báo, gán, làm các phép toán số học dẫn đến tràn thì overflow sẽ được khắc phục bằng cách cắt bớt chuỗi bit bị tràn ra khỏi size của  bộ nhớ biến. Trường hợp khác (ví dụ như cấp phát vùng nhớ, ép kiểu trong thao tác..) thì C/C++ cho phép sự tràn xảy ra trong chương trình, các binary code được lưu 1 cách bất chấp (tràn sang ô nhớ bên cạnh) và thường thì lập trình viên phải chịu trách nhiệm trước lỗi này bằng cách tự khắc phục nó bằng bất kỳ cách nào.

1. TRƯỜNG HỢP 1

Xét ví dụ bên dưới:

#include <stdio.h>
int main()
{
	char i;
	for(i = 0; i < 128; i++)
	{}
	return 0;
}

Thoạt nhìn có lẽ đa số sẽ nghĩ dòng for này hoàn toàn không có gì đặc biệt. Nhưng thực chất trong trường hợp này nó là 1 vòng lặp vô hạn.

Để giải thích cho điều này không khó. Có 1 biến char i; suy ra biến i của sẽ được cấp 1 vùng nhớ 1 byte = 8 bit trong bộ nhớ. Khi vào dòng for thì biến i đã vô tình được chuyển thành kiểu int có kích thước 1 byte, và giới hạn của biến int này từ -128 đến 127. Theo chuẩn số bù 2 thì 8 bit lưu trữ biến i sẽ có 1 bit cao nhất quyết định dấu của số đó (bit 0 nếu là số dương và 1 nếu âm). Chạy từ i = 0 đến i = 127 mọi việc vẫn tốt đẹp bình thường, khi đến cuối dòng for của giá trị i = 127 thì i tự động tăng lên 1 theo nguyên lý dòng for.

i tăng lên 1 nên binary code của i lúc này là 10000000, theo chuẩn số bù 2 thì giá trị này cho thấy i = -128 thấy -128 nhỏ hơn 128 nên vòng for tiếp tục. Biến i vẫn tăng lên đến lúc i = -1 thì binary code của i lúc này là 11111111, sau đó i tiếp tục tăng thêm 1 đơn vị và binary code trở thành 100000000, vì ô nhớ chưa biến i chỉ có 8 bit nên nó tự động loại bỏ bit ở vị trí cao nhất (bit 1) và khiến cho binary code của i lúc này là 00000000 biểu diễn số 0. Vòng lặp mới bắt đầu tại đây.

Đến đây hẳn đã nắm rõ nguyên lý làm việc của 1 biến khi bị overflow. Khi ấy chuỗi bit sẽ được cắt bớt ở phía những bit cao nhất đến khi nào số bit còn lại vừa vặn với phân khúc vùng nhớ cho phép thì thôi. Nhấn mạnh ở đây rằng: "Trong 1 chương trình, không nhất thiết cứ tăng n lên 1 thì giá trị sau khi tăng là n + 1, điều tương tự với việc giảm n đi 1 đơn vị".

2. TRƯỜNG HỢP 2

Trong 1 trường hợp khác thì C/C++ cho 1 cách xử lý khác. Sự tràn này được bỏ qua và đưa ra 1 lỗi buộc lập trình viên phải tự khắc phục.

Xét 2 ví dụ bên dưới:

Ví dụ 1:

#include <stdio.h>
int main()
{
	char i;
	int* p = (int*) &i;
	i = 100;
	printf("%d %d", *p, i);
    p = NULL; // biến i đang nằm trên stack nên không thể ra lệnh hủy p;
	return 0;
}

Trong trường hợp này khi chạy chương trình không có lỗi. Màn hình in ra 2 giá trị -858993564100. Tại sao lại là -858993564100? Giải thích là vì khi thao tác với biến i = 100 thì chỉ có mỗi 1 vùng nhớ biến i được thay đổi thành giá trị 100, còn lại 3 byte kia không hề thay đổi gì cả. Điều này khiến cho 2 giá trị *pi khác nhau. Nhưng kết thúc chương trình lại không có lỗi nào được xảy ra cả. Nguyên nhân ở đây là vì vùng nhớ 3 byte nằm ngoài biến i của con trỏ p không bị corrupted khi thao tác trong chương trình này.

Ví dụ 2:

#include <stdio.h>
int main()
{
	char i;
	int* p = (int*) &i;
	*p = 100;
	printf("%d %d", *p, i);
    p = NULL; // biến i đang nằm trên stack nên không thể ra lệnh hủy p;
	return 0;
}

Trường hợp này sẽ có lỗi ở cuối chương trình. Lúc mà vùng nhớ được giải phóng. Và sản phẩm được in ra màn hình là 100100. Khác hẳn với đoạn code ở ví dụ 1.

Để giải thích vì sao phải lướt qua kiến thức về little endian (Tham khảo bài viết Little Endian Và Big Endian). Thấy rằng trong 4 byte của biến int, không phải tất cả 4 byte của biến int được lưu lần lượt vào trong ô nhớ đã được cấp phát mà là byte nằm trái cùng (cao nhất) của biến i được lưu vào byte phải cùng (thấp nhất) được cấp phát cho biến đó trong bộ nhớ. Và lần lượt mỗi lần lưu 8 bit vào trong 1 byte.

Cụ thể là thế này:

Lấy 8 bit của byte cao nhất đã được mã hóa đưa vào byte thấp nhất của vùng nhớ cấp phát cho biến, và cứ thế cho đến khi hết byte. Phần còn lại bị tràn ra thì được cắt bỏ. Nếu vậy thì giả sử biến int i = 69 được dịch từ thập phân ra binary code sẽ đượcđược:

00000000 00000000 00000000 01000101

Nhưng đưa vào trong ô nhớ thì sẽ lưu như sau:

01000101 00000000 00000000 00000000

Từ đó hiểu ở đoạn code trên, các ô nhớ được mô phỏng như sau:

Khi chương trình đọc giá trị của biến, nó sẽ đọc từ byte thấp đến byte cao để lấy được giá trị biến đó. Cách thao tác này rất phù hợp cho việc ép kiểu trong quá trình viết code. Vì tổ chức ô nhớ theo nguyên lý trên đã trình bày. Nên 2 giá trị *pi đều bằng 100. Nhưng khi kết thúc chương trình lại xảy ra lỗi.

Vấn đề ở đây là lỗi gì và tại sao? Có câu trả lời như bên dưới:

Lỗi corrupted, lỗi này thường gặp khi thao tác với vùng nhớ mà không có quyền chạm đến. Cụ thể ở đây là có biến int* p chỉ có quyền hạn điều khiển được biến i có dung lượng 1 byte, nhưng trong nguyên tắc cấp phát biến int thì nó được quyền quản lý đến 4 byte, vậy đã có 3 byte bị chương trình hiểu nhầm là có quyền truy cập trong trường hợp này. 3 byte này không được phép sử dụng nhưng vẫn bị sử dụng ở dòng lệnh *p = 100 làm thay đổi nội dung 3 byte này. Nên cuối chương trình lỗi đã xảy ra. Và trường hợp này, không còn cách nào khác ngoại trừ việc lập trình viên phải chọn cho mình 1 cách thức khác để thực hiện vấn đề này.

Tổng kết

Trong việc lập trình, hoàn toàn có thể kiểm soát size của biến cần dùng và chức năng của từng biến do chính lập trình viên qui định. Để giảm thiểu những lỗi không rõ ràng về memory, thì điều tốt nhất đó chính là tập khả năng kiểm soát biến ngay từ bây giờ. Luôn nhớ rằng khi new bất kỳ 1 cái gì đó thì phải delete nó khi không còn sử dụng nữa. Và đừng bao giờ cho phép quên sizeof của từng kiểu biến.

Tài liệu tham khảo

  • Memory as programming concept in C/C++ - Frantisek Franek.
  • Các tài liệu khác tham khảo thông qua internet.
IO Stream

IO Stream Co., Ltd

30 Trinh Dinh Thao, Hoa Thanh ward, Tan Phu district, Ho Chi Minh city, Vietnam
+84 28 22 00 11 12
developer@iostream.co

383/1 Quang Trung, ward 10, Go Vap district, Ho Chi Minh city
Business license number: 0311563559 issued by the Department of Planning and Investment of Ho Chi Minh City on February 23, 2012

©IO Stream, 2013 - 2024