2.2. Tính an toàn dữ liệu

Estimated reading: 23 minutes 133 views

Vòng đời (Lifespan) của biến

Để tạo ra một chương trình “hoạt động chính xác và an toàn”, ta phải bảo vệ dữ liệu khỏi các thao tác bất hợp pháp. Vì vậy, hãy thử chú ý đến quá trình thay đổi mà các biến – vật chứa dữ liệu – trải qua trong chương trình, hay nói cách khác là Vòng đời (lifetime / ライフタイム) của biến.

Fig. 2-1 + Vòng đời của biến trong một chương trình hoạt động chính xác

Như hiển thị trong Fig. 2-1, vòng đời của biến bao gồm 5 giai đoạn:

(1) Cấp phát vùng nhớ dựa trên thông tin kiểu (Acquisition of memory area) 

(2) Khởi tạo thích hợp 

(3) Thay đổi giá trị thích hợp 

(4) Tham chiếu (lấy) kết quả 

(5) Xử lý hậu kỳ và giải phóng vùng nhớ (huỷ instance)

Một chương trình mà tất cả các giai đoạn này đều được thực hiện một cách thích hợp là một chương trình “hoạt động chính xác và an toàn”. Vậy thì, tôi sẽ giải thích xem nguyên nhân khiến việc xử lý bất hợp pháp xen vào các giai đoạn nằm ở đâu, và C++ làm thế nào để ngăn chặn các xử lý bất hợp pháp đó.

Cấp phát vùng nhớ

Thực thể của biến và phương pháp bảo đảm bộ nhớ

Biến là thứ tồn tại trong bộ nhớ, nhưng không có nghĩa là nó tồn tại trong bộ nhớ ngay từ đầu. Thứ tồn tại ban đầu chỉ là thông tin kiểu mô tả tính chất của biến. Chỉ khi một vùng nhớ nào đó trên bộ nhớ được cấp phát cho biến dựa trên thông tin kiểu đó, biến mới có thể tồn tại như một thực thể trên bộ nhớ. Thực thể của biến đang tồn tại trên bộ nhớ như vậy được gọi là Bản thể (instance / インスタンス) (Fig. 2-1-(1)). Để cấp phát vùng nhớ, có 2 phương pháp là:

  • Cấp phát vùng nhớ tĩnh (static) và 
  • Cấp phát vùng nhớ động (dynamic)

Trong ngôn ngữ C, phương pháp đầu tiên được thực hiện thông qua khai báo/định nghĩa biến, còn phương pháp sau được thực hiện bởi các hàm chuẩn như hàm thư viện chuẩn malloc(). List 2-1 là một ví dụ điển hình về việc cấp phát vùng nhớ (ngôn ngữ C). Có 3 biến sau đây xuất hiện:

・a kiểu int 

・p kiểu person* 

・Biến vô danh kiểu person (do hàm malloc tạo ra)

Biến a và biến p sẽ được cấp phát vùng nhớ và trở thành bản thể / instance tại thời điểm xử lý tiến đến dòng 12, 13 nơi chúng được khai báo. Đây là cấp phát bộ nhớ tĩnh. Ở dòng 15, để tạo ra một biến vô danh kiểu person bằng hàm malloc(), vùng nhớ được cấp phát động. Vì cần phải truyền kích thước vùng nhớ cần cấp phát cho malloc(), nên ta truyền sizeof( person ) làm tham số thực. malloc() trả về con trỏ kiểu void* trỏ tới vùng nhớ đã cấp phát, và trả về con trỏ rỗng (null pointer) nếu thất bại trong việc cấp phát vùng nhớ. Vùng nhớ đã cấp phát động có thể được giải phóng bằng cách truyền con trỏ trỏ tới vùng nhớ đó cho hàm chuẩn free() của ngôn ngữ C. Thực ra, phương pháp cấp phát bộ nhớ động này đã được cải thiện đáng kể trong C++.

List 2-1 + Cấp phát vùng nhớ (life1.c)

				
					1: #include <stdio.h>
2: #include <stdlib.h> /* include để dùng hàm malloc */
3: #include <string.h>
4:
5: typedef struct person { /* Kiểu cấu trúc (structure type / 構造体型) person biểu thị thông tin con người */
6:     char name[ 64 ];
7:     int age;
8: } person;
9:
10: int main( void )
11: {
12:     int a = 81;
13:     person * p = NULL; /* Hằng số macro NULL biểu thị con trỏ rỗng */
14:
15:     p = malloc( sizeof( person ) ); /* Cấp phát vùng nhớ động */
16:
17:     /* Kiểm tra thất bại khi cấp phát vùng nhớ động */
18:     if( p == NULL ) return (EXIT_FAILURE);
19:
20:     /* Khởi tạo giá trị */
21:     strcpy( p->name, "J. R. R. Tolkien" );
22:     p->age = a;
23:     /* Tham chiếu giá trị */
24:     printf( "name : %s\n", p->name );
25:     printf( "age  : %d\n", p->age );
26:
27:     free( p ); /* Giải phóng vùng nhớ đã cấp phát động */
28:     p = NULL;
29:
30:     return 0;
31: }				
			

List 2-2 + Viết lại List 2-1 bằng C++ (life2.cpp)

				
					1: #include <iostream>
2: #include <cstring> // Tương đương với string.h của thư viện ngôn ngữ C
3: using namespace std;
4:
5: typedef struct person {
6:     char name[ 64 ];
7:     int age;
8: } person;
9:
10: int main()
11: {
12:     int a = 81;
13:     person * p = 0; // Con trỏ rỗng của C++ là hằng số nguyên 0
14:
15:     p = new person; // Cấp phát vùng nhớ động
16:
17:     // Việc kiểm tra thất bại khi cấp phát vùng nhớ động được lược bỏ
18:     // Chi tiết hãy tham khảo Level 9
19:
20:     // Khởi tạo giá trị
21:     strcpy( p->name, "J. R. R. Tolkien" );
22:     p->age = a;
23:     // Tham chiếu giá trị
24:     cout << "name : " << p->name << endl
25:          << "age  : " << p->age  << endl;
26:
27:     delete p; // Giải phóng vùng nhớ đã cấp phát động
28:     p = 0;
29:
30:     return 0;
31: }				
			

Fig. 2-2 + Cú pháp cơ bản của toán tử new

(1) Cú pháp thông thường

new Tên_kiểu_T

Toán hạng: Nhận tên kiểu T ở toán hạng bên phải

Hoạt động: Cấp phát vùng nhớ cho bản thể /instance của kiểu T

Giá trị trả về: Giá trị trả về là con trỏ tới bản thể / instance được tạo ra (kiểu T*)

Ví dụ:

int * iptr = new int;

(2) Khi cấp phát vùng nhớ cho mảng

new Tên_kiểu_T [Số_phần_tử]

Toán hạng: Nhận Tên_kiểu_T [Số_phần_tử] ở toán hạng bên phải

Hoạt động: Cấp phát vùng nhớ cho bản thể / instance của mảng kiểu T

Giá trị trả về: Giá trị trả về là con trỏ tới phần tử thứ nhất của bản thể / instance mảng được tạo ra (kiểu T*)

Ví dụ:

int * iarray100 = new int[ 100 ];

※ Trường hợp cấp phát động thất bại, toán tử new sẽ ném ra ngoại lệ kiểu bad_alloc (Level 8.3)

Toán tử new

List 2-2 là nội dung của List 2-1 được viết lại bằng C++, trong đó nội dung tương ứng với từng dòng của List 2-1 được viết trên cùng một dòng. Thử nhìn vào dòng 15 của List 2-2, nó như sau:

p = new person;

So với dòng 15 của List 2-1 sử dụng hàm malloc(), nó ngắn gọn hơn rất nhiều. new này là toán tử của C++, nó nhận tên kiểu ở bên phải và thực hiện việc cấp phát động vùng nhớ dành cho biến của kiểu đó. Cách sử dụng toán tử new được trình bày trong Fig. 2-2. Trong ngôn ngữ C, ta cũng có thể cấp phát dễ dàng bằng calloc() (Fig. 2-2-(2)).

Điểm cải tiến trong việc cấp phát vùng nhớ bởi toán tử new

Một điểm cải tiến so với ngôn ngữ C nhờ việc đưa vào toán tử new là việc mô tả cấp phát vùng nhớ động trở nên đơn giản hơn. Ngoài ra, vì chức năng đã được chuyển từ hàm chuẩn sang toán tử, nên cũng không cần include các header như stdlib.h. Tuy nhiên, điểm cải tiến quan trọng nhất là việc ít xảy ra sai sót hơn khi cấp phát vùng nhớ động. Trong List 2-3, tôi sẽ chỉ ra một ví dụ điển hình về sai sót dễ xảy ra trong ngôn ngữ C. Trong chương trình này, hàm malloc() được sử dụng để tạo một bản thể kiểu double, nhưng toán hạng của toán tử sizeof lại là char chứ không phải double. Kích thước của bản thể thực tế *dptr được trỏ bởi con trỏ không khớp với kích thước mà kiểu dự kiến double yêu cầu, vì vậy nếu cứ thế thực hiện xử lý nào đó trên instance *dptr, rõ ràng chương trình sẽ hoạt động bất thường. Hơn nữa, điều này không gây ra lỗi biên dịch (compile error). Đó là bởi vì malloc() trả về kiểu void*, và kiểu void* có thể được chuyển đổi thành bất kỳ kiểu con trỏ nào, nên việc gán vào biến dptr kiểu double* được cho phép.

List 2-3 + Ví dụ về lỗi dễ xảy ra trong ngôn ngữ C (malloc1.c)

				
					1: #include <stdlib.h>
2:
3: int main( void )
4: {
5:     /* Dòng tiếp theo không gây lỗi biên dịch (Nguy hiểm!) */
6:     double * dptr = malloc( sizeof( char ) );
7:     * dptr = 1.0; /* Nguyên nhân hoạt động sai */
8:     return 0;
9: }				
			

List 2-4 là phiên bản viết lại theo phong cách C++ của List 2-3. Trong trường hợp này, kiểu trả về của new là char*, kiểu của nơi gán là double*, nên lỗi không khớp kiểu sẽ được phát hiện tại thời điểm biên dịch, và sự nguy hiểm sẽ không bị bỏ qua như trong List 2-3. Ngoài ra, hãy lưu ý rằng tính an toàn không được đảm bảo nếu giải phóng vùng nhớ của bản thể được tạo bởi new bằng hàm chuẩn free() của ngôn ngữ C.

List 2-4 + Trong C++ sai sót sẽ được phát hiện (new1.cpp)

				
					1: int main()
2: {
3:     // Dòng tiếp theo sẽ gây lỗi biên dịch
4:     double * dptr = new char;
5:     * dptr = 1.0;
6:     return 0;
7: }				
			
				
					1: #include <stdio.h> /* Stack đơn giản */
2:
3: #define STACK_SIZE (100)
4:
5: /* Định nghĩa kiểu Stack lưu trữ dữ liệu kiểu char */
6: typedef struct Stack {
7:     int sp;
8:     char buff[ STACK_SIZE ];
9: } Stack;
10:
11: /* Thao tác Push */
12: void push( Stack * s, const char c ) {
13:     if( s == NULL || s->sp >= STACK_SIZE )
14:     {
15:         printf( "\nerror!!\n" ); return;
16:     }
17:     s->buff[ (s->sp)++ ] = c;
18: }
19:
20: /* Thao tác Pop */
21: char pop( Stack * s ) {
22:     if( s == NULL || s->sp <= 0 )
23:     {
24:         printf( "\nerror!!\n" ); return '\0';
25:     }
26:     return s->buff[ --(s->sp) ];
27: }
28:
29: int main( void )
30: {
31:     Stack s1 = { 0 };
32:
33:     push( &s1, 'p' ); push( &s1, 'e' );
34:     push( &s1, 'e' ); push( &s1, 'k' );
35:     printf( "%c", pop( &s1 ) );
36:     printf( "%c", pop( &s1 ) );
37:     s1.sp = 1; /* Thao tác bất hợp pháp (Nguy hiểm!) */
38:     printf( "%c", pop( &s1 ) );
39:     printf( "%c", pop( &s1 ) );
40:
41:     return 0;
42: }				
			
				
					1: #include <stdio.h> /* Stack đơn giản */
2:
3: #define STACK_SIZE (100)
4:
5: /* Định nghĩa kiểu Stack lưu trữ dữ liệu kiểu char */
6: typedef struct Stack {
7:     int sp;
8:     char buff[ STACK_SIZE ];
9: } Stack;
10:
11: /* Thao tác Push */
12: void push( Stack * s, const char c ) {
13:     if( s == NULL || s->sp >= STACK_SIZE )
14:     {
15:         printf( "\nerror!!\n" ); return;
16:     }
17:     s->buff[ (s->sp)++ ] = c;
18: }
19:
20: /* Thao tác Pop */
21: char pop( Stack * s ) {
22:     if( s == NULL || s->sp <= 0 )
23:     {
24:         printf( "\nerror!!\n" ); return '\0';
25:     }
26:     return s->buff[ --(s->sp) ];
27: }
28:
29: int main( void )
30: {
31:     Stack s1 = { 0 };
32:
33:     push( &s1, 'p' ); push( &s1, 'e' );
34:     push( &s1, 'e' ); push( &s1, 'k' );
35:     printf( "%c", pop( &s1 ) );
36:     printf( "%c", pop( &s1 ) );
37:     s1.sp = 1; /* Thao tác bất hợp pháp (Nguy hiểm!) */
38:     printf( "%c", pop( &s1 ) );
39:     printf( "%c", pop( &s1 ) );
40:
41:     return 0;
42: }				
			

Nhân tiện, khi malloc() hay calloc() thất bại trong việc cấp phát động bộ nhớ, chúng trả về con trỏ rỗng. Vậy, nếu toán tử new thất bại trong việc cấp phát động bộ nhớ thì sẽ ra sao?. Và, phương pháp kiểm tra sự thất bại đó là gì? Thực ra, trong C++ có sẵn một cơ chế cho phép xử lý lỗi thanh lịch gọi là “Ngoại lệ” (exception), và toán tử new khi thất bại sẽ sử dụng chức năng ngoại lệ này để thông báo thất bại. Vì cơ chế ngoại lệ sẽ được giải thích chi tiết ở Level 8 (☞ tr. 317), nên tạm thời chúng ta sẽ sử dụng toán tử new mà không kiểm tra lỗi.

Khởi tạo thích hợp

Giai đoạn thứ 2 trong vòng đời của biến là khởi tạo thích hợp giá trị bản thể (Fig. 2-1-(2)). Nếu không khởi tạo thích hợp, ta sẽ tham chiếu bản thể trong trạng thái giá trị không xác định, dẫn đến nguyên nhân của những hoạt động sai lệch không mong muốn. Trong chương trình ngôn ngữ C ở List 2-1, việc cấp phát bộ nhớ tĩnh được thực hiện đồng thời với việc khởi tạo thông qua khai báo biến (dòng 12-13). Điều này cũng tương tự trong List 2-2 viết bằng C++. Tuy nhiên, trong C++ có điểm khác là sử dụng hằng số nguyên 0 làm con trỏ rỗng. Tại sao trong C++ lại không dùng NULL? Đó là vì tiêu chuẩn C++ được thiết kế để giảm thiểu sự phụ thuộc vào bộ tiền xử lý (preprocessor). Hằng số macro NULL vẫn có thể sử dụng được, nhưng khi muốn dùng con trỏ rỗng trong C++, hãy cố gắng sử dụng hằng số nguyên 0. Quay lại chủ đề chính. Việc khởi tạo đồng thời với cấp phát bộ nhớ là điều cần thiết không thể thiếu để làm cho các lỗi như tham chiếu giá trị biến không xác định khó xảy ra hơn. Tuy nhiên, trong ngôn ngữ C, trường hợp cấp phát vùng nhớ động, ta không thể thực hiện khởi tạo đồng thời. Trong List 2-1, việc cấp phát bộ nhớ động được thực hiện ở dòng 15, còn khởi tạo được thực hiện ở dòng 21~22 (strcpy() là hàm sao chép chuỗi ký tự được chỉ định ở tham số thứ 2 vào mảng ký tự được chỉ ra ở tham số thứ 1). Trong C++, điểm này như thế nào? Trong List 2-2 chưa giới thiệu, nhưng thực ra trong C++ đã có thể thực hiện đồng thời việc cấp phát bộ nhớ động và khởi tạo bằng một phương pháp rất hay. Ta có thể nắm được ý tưởng của phương pháp đó trong quá trình xem xét chi tiết các vấn đề của giai đoạn tiếp theo.

Thay đổi giá trị thích hợp

Nguy cơ giá trị của instance bị thay đổi bất hợp pháp

Sau khi bản thể / instance được khởi tạo, giá trị của bản thể phải được thay đổi lần lượt theo trình tự (thuật toán) phù hợp với mục đích (Fig. 2-1-(3)). Đáng tiếc là chức năng đảm bảo tính đúng đắn của thuật toán không được hỗ trợ trong hầu hết các ngôn ngữ lập trình, bao gồm cả C/C++.

Fig. 2-3 + Thao tác bất hợp pháp là nguồn gốc của lỗi (bug)

Ở đây, giả sử thuật toán là đúng, hãy cùng suy nghĩ về nguy cơ giá trị của bản thể bị thay đổi một cách bất hợp pháp. Fig. 2-3 minh họa việc giá trị của bản thể bị phá hủy bởi thao tác bất hợp pháp. Khi thao tác bất hợp pháp như vậy được thực hiện, dù thuật toán có đúng đến đâu thì chương trình cũng sẽ không hoạt động chính xác.

Ngăn xếp (Stack)

Để làm ví dụ, hãy xem xét một Ngăn xếp (stack) đơn giản được viết bằng ngôn ngữ C. Stack là cấu trúc dữ liệu thường được sử dụng trong chương trình. Ý nghĩa của từ tiếng Anh stack là “chồng chất”. Có thể sẽ dễ hiểu nếu tưởng tượng việc chồng các tấm nệm ngồi vào trong hộp chứa đồ (Fig. 2-4). Ví dụ, nếu xếp nệm vào hộp chứa theo thứ tự nệm nâu, nệm trắng, nệm đỏ (Fig. 2-4-(1)), thì dưới cùng hộp chứa sẽ là nệm nâu, trên đó là nệm trắng, và trên cùng sẽ là nệm đỏ (Fig. 2-4-(2)). 

Fig. 2-4 + Khái niệm Stack ví von với hộp chứa đồ và nệm ngồi

Lúc này, từ bên ngoài ta chỉ có thể nhìn thấy cái nằm ở trên cùng là chiếc nệm màu đỏ. Và khi lấy nệm ra khỏi hộp chứa, ta sẽ phải lấy từ chiếc nệm màu đỏ đang xếp trên cùng (Fig. 2-4-(3)). Tóm lại, sẽ trở thành “lấy ra từ cái được đưa vào cuối cùng”. Như vậy, việc “chồng dữ liệu lên, và khi lấy ra thì lấy từ trên cùng” sẽ tự nhiên sinh ra tính chất “xuất dữ liệu từ cái được nhập vào sau cùng”. Tính chất này được gọi là “Vào sau ra trước” (last-in-first-out, LIFO). Cấu trúc dữ liệu mang tính chất “Vào sau ra trước” được gọi là stack (ngăn xếp). Việc nhập dữ liệu vào stack (chồng lên trên cùng) được gọi là push (đẩy vào), việc xuất dữ liệu từ stack (lấy ra từ trên cùng) được gọi là pop (lấy ra). Ngoài ra, vị trí có dữ liệu trên cùng của stack được gọi là top (đỉnh) của stack, vị trí có dữ liệu dưới cùng được gọi là bottom (đáy) với ý nghĩa là “đáy”. Hãy chú ý rằng dữ liệu có thể tham chiếu giá trị tại một thời điểm nhất định chỉ là dữ liệu nằm ở vị trí top.

Cài đặt Stack sử dụng mảng

Ở đây, chúng ta hãy thử cài đặt stack bằng cách sử dụng mảng (Fig. 2-5). Chỉ cần coi thao tác chèn phần tử vào cuối dãy phần tử được lưu trong mảng là push, thao tác lấy phần tử cuối cùng ra và xóa đi là pop. Vị trí phần tử cuối cùng sẽ là top của stack, vị trí phần tử đầu tiên sẽ là bottom của stack. 

Fig. 2-5 + Sử dụng mảng làm Stack

List 2-5 trình bày ví dụ về chương trình ngôn ngữ C cài đặt stack bằng mảng. Trong ví dụ này, ta định nghĩa kiểu stack tên là Stack có khả năng lưu trữ tối đa STACK_SIZE dữ liệu kiểu char. Thành viên sp biểu thị số lượng phần tử đang được lưu trữ tại thời điểm đó, và

buff[ Giá trị của sp - 1 ]

sẽ là phần tử nằm ở top của stack. Khi thực thi List 2-5, lẽ ra phải hiển thị là:

keep

nhưng mà lại hiển thị,

kep
error!! 

→ không thu được kết quả thực thi như mong đợi. Điều này là do giá trị thành viên sp của stack đã bị thao tác bất hợp pháp ở dòng 37.

List 2-5 + Ví dụ về thao tác bất hợp pháp (stack1.c)

				
					1: #include <stdio.h> /* Stack đơn giản */
2:
3: #define STACK_SIZE (100)
4:
5: /* Định nghĩa kiểu Stack lưu trữ dữ liệu kiểu char */
6: typedef struct Stack {
7:     int sp;
8:     char buff[ STACK_SIZE ];
9: } Stack;
10:
11: /* Thao tác Push */
12: void push( Stack * s, const char c ) {
13:     if( s == NULL || s->sp >= STACK_SIZE )
14:     {
15:         printf( "\nerror!!\n" ); return;
16:     }
17:     s->buff[ (s->sp)++ ] = c;
18: }
19:
20: /* Thao tác Pop */
21: char pop( Stack * s ) {
22:     if( s == NULL || s->sp <= 0 )
23:     {
24:         printf( "\nerror!!\n" ); return '\0';
25:     }
26:     return s->buff[ --(s->sp) ];
27: }
28:
29: int main( void )
30: {
31:     Stack s1 = { 0 };
32:
33:     push( &s1, 'p' ); push( &s1, 'e' );
34:     push( &s1, 'e' ); push( &s1, 'k' );
35:     printf( "%c", pop( &s1 ) );
36:     printf( "%c", pop( &s1 ) );
37:     s1.sp = 1; /* Thao tác bất hợp pháp (Nguy hiểm!) */
38:     printf( "%c", pop( &s1 ) );
39:     printf( "%c", pop( &s1 ) );
40:
41:     return 0;
42: }				
			

Leave a Comment

Chia sẻ:

2.2. Tính an toàn dữ liệu

Or copy link

CONTENTS