Week 4 Bộ nhớ

Chào mừng các bạn!

  • Trong những tuần trước, chúng ta đã nói về việc hình ảnh được tạo thành từ các khối xây dựng nhỏ hơn gọi là pixel.

  • Hôm nay, chúng ta sẽ đi sâu vào chi tiết về các số không và một tạo nên những hình ảnh này. Cụ thể, chúng ta sẽ tìm hiểu kỹ hơn về các khối xây dựng cơ bản tạo nên các tệp tin, bao gồm cả hình ảnh.

  • Hơn nữa, chúng ta sẽ thảo luận về cách truy cập dữ liệu bên dưới được lưu trữ trong bộ nhớ máy tính.

  • Khi bắt đầu bài học hôm nay, hãy biết rằng các khái niệm trong bài giảng này có thể cần một thời gian để thực sự vỡ lẽ.

Pixel Art

  • Pixel là những ô vuông, những chấm màu riêng lẻ, được sắp xếp trên một lưới dọc-ngang.

Bạn có thể hình dung một hình ảnh như một bản đồ các bit, trong đó số 0 đại diện cho màu đen và số 1 đại diện cho màu trắng.

Hệ thập lục phân (Hexadecimal)

RGB, hay red, green, blue (đỏ, xanh lá, xanh dương), là các con số đại diện cho lượng của mỗi màu này. Trong Adobe Photoshop, bạn có thể thấy các cài đặt này như sau:

Lưu ý cách lượng màu đỏ, xanh dương và xanh lá cây thay đổi màu sắc được chọn.

  • Bạn có thể thấy từ hình ảnh trên rằng màu sắc không chỉ được đại diện bởi ba giá trị. Ở phía dưới cùng của cửa sổ, có một giá trị đặc biệt được tạo thành từ các con số và ký tự. 255 được biểu diễn là FF. Tại sao lại như vậy?

Hexadecimal (Hệ thập lục phân) là một hệ thống đếm có 16 giá trị. Chúng như sau:

  0 1 2 3 4 5 6 7 8 9 A B C D E F

Lưu ý rằng F đại diện cho 15.

  • Hexadecimal còn được gọi là hệ cơ số 16.

  • Khi đếm trong hệ thập lục phân, mỗi cột là một lũy thừa của 16.

  • Số 0 được biểu diễn là 00.

  • Số 1 được biểu diễn là 01.

  • Số 9 được biểu diễn bởi 09.

  • Số 10 được biểu diễn là 0A.

  • Số 15 được biểu diễn là 0F.

  • Số 16 được biểu diễn là 10.

  • Số 255 được biểu diễn là FF, vì 16 x 15 (hay F) là 240. Cộng thêm 15 nữa để thành 255. Đây là con số cao nhất bạn có thể đếm bằng hệ thống thập lục phân hai chữ số.

  • Hệ thập lục phân hữu ích vì nó có thể được biểu diễn bằng ít chữ số hơn. Hệ thập lục phân cho phép chúng ta biểu diễn thông tin một cách ngắn gọn hơn.

Bộ nhớ

Trong những tuần trước, bạn có thể nhớ lại hình ảnh minh họa về các khối bộ nhớ liền kề. Áp dụng đánh số thập lục phân cho mỗi khối bộ nhớ này, bạn có thể hình dung chúng như sau:

Bạn có thể tưởng tượng sẽ có sự nhầm lẫn về việc liệu khối 10 ở trên đại diện cho một vị trí trong bộ nhớ hay giá trị 10. Do đó, theo quy ước, tất cả các số thập lục phân thường được biểu diễn với tiền tố 0x như sau:

Trong cửa sổ terminal, gõ code addresses.c và viết mã của bạn như sau:

// Prints an integer

#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%i\n", n);
}

Lưu ý cách n được lưu trữ trong bộ nhớ với giá trị 50.

Bạn có thể hình dung cách chương trình này lưu trữ giá trị này như sau:

Con trỏ (Pointers)

Ngôn ngữ C có hai toán tử mạnh mẽ liên quan đến bộ nhớ:

  & Cung cấp địa chỉ của một thứ gì đó được lưu trữ trong bộ nhớ.
  * Chỉ dẫn trình biên dịch đi đến một vị trí trong bộ nhớ.

Chúng ta có thể tận dụng kiến thức này bằng cách sửa đổi mã của mình như sau:

// Prints an integer's address

#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%p\n", &n);
}

Lưu ý %p, nó cho phép chúng ta xem địa chỉ của một vị trí trong bộ nhớ. &n có thể được dịch sát nghĩa là “địa chỉ của n.” Thực thi mã này sẽ trả về một địa chỉ bộ nhớ bắt đầu bằng 0x.

  • Một pointer (con trỏ) là một biến lưu trữ địa chỉ của một thứ gì đó. Nói một cách ngắn gọn nhất, con trỏ là một địa chỉ trong bộ nhớ máy tính của bạn.

Hãy xem xét đoạn mã sau:

int n = 50;
int *p = &n;

Lưu ý rằng p là một con trỏ chứa địa chỉ của một số nguyên n.

Sửa đổi mã của bạn như sau:

// Stores and prints an integer's address

#include <stdio.h>

int main(void)
{
    int n = 50;
    int *p = &n;
    printf("%p\n", p);
}

Lưu ý rằng mã này có tác dụng tương tự như mã trước đó của chúng ta. Chúng ta chỉ đơn giản là tận dụng kiến thức mới về các toán tử &*.

Để minh họa việc sử dụng toán tử *, hãy xem xét điều sau:

// Stores and prints an integer via its address

#include <stdio.h>

int main(void)
{
    int n = 50;
    int *p = &n;
    printf("%i\n", *p);
}

Lưu ý rằng dòng printf in số nguyên tại vị trí của p. int *p tạo ra một con trỏ có nhiệm vụ lưu trữ địa chỉ bộ nhớ của một số nguyên.

Bạn có thể hình dung mã của chúng ta như sau:

Lưu ý rằng con trỏ có vẻ khá lớn. Thực vậy, một con trỏ thường được lưu trữ dưới dạng giá trị 8-byte. p đang lưu trữ địa chỉ của số 50.

Bạn có thể hình dung một con trỏ chính xác hơn như một địa chỉ trỏ đến một địa chỉ khác:

Chuỗi (Strings)

  • Bây giờ chúng ta đã có một mô hình tư duy về con trỏ, chúng ta có thể bóc tách một lớp đơn giản hóa đã được giới thiệu trước đó trong khóa học này.

Sửa đổi mã của bạn như sau:

// Prints a string

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string s = "HI!";
    printf("%s\n", s);
}

Lưu ý rằng một chuỗi s được in ra.

Hãy nhớ lại rằng một chuỗi chỉ đơn giản là một mảng các ký tự. Ví dụ, string s = "HI!" có thể được biểu diễn như sau:

Tuy nhiên, thực sự s là gì? s được lưu trữ ở đâu trong bộ nhớ? Như bạn có thể hình dung, s cần được lưu trữ ở đâu đó. Bạn có thể hình dung mối quan hệ của s với chuỗi như sau:

Lưu ý cách một con trỏ tên là s cho trình biên dịch biết byte đầu tiên của chuỗi nằm ở đâu trong bộ nhớ.

Sửa đổi mã của bạn như sau:

// Prints a string's address as well the addresses of its chars

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string s = "HI!";
    printf("%p\n", s);
    printf("%p\n", &s[0]);
    printf("%p\n", &s[1]);
    printf("%p\n", &s[2]);
    printf("%p\n", &s[3]);
}

Lưu ý phần trên in ra các vị trí bộ nhớ của từng ký tự trong chuỗi s. Ký hiệu & được sử dụng để hiển thị địa chỉ của từng phần tử của chuỗi. Khi chạy mã này, hãy lưu ý rằng các phần tử 0, 1, 23 nằm cạnh nhau trong bộ nhớ.

Tương tự, bạn có thể sửa đổi mã của mình như sau:

// Declares a string with CS50 Library

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string s = "HI!";
    printf("%s\n", s);
}

Lưu ý rằng mã này sẽ hiển thị chuỗi bắt đầu tại vị trí của s. Mã này loại bỏ hiệu quả “bánh xe tập đi” của kiểu dữ liệu string được cung cấp bởi thư viện cs50.h. Đây là mã C thuần túy, không có khung đỡ của thư viện CS50.

Bỏ đi bánh xe tập đi, bạn có thể sửa đổi lại mã của mình:

// Declares a string without CS50 Library

#include <stdio.h>

int main(void)
{
    char *s = "HI!";
    printf("%s\n", s);
}

Lưu ý rằng cs50.h đã được loại bỏ. Một chuỗi được triển khai dưới dạng một char *.

  • Bạn có thể hình dung cách một chuỗi, với tư cách là một kiểu dữ liệu, được tạo ra.

  • Tuần trước, chúng ta đã học cách tạo kiểu dữ liệu riêng dưới dạng một struct.

  • Thư viện CS50 bao gồm một struct như sau: typedef char *string

  • Struct này, khi sử dụng thư viện CS50, cho phép một người sử dụng một kiểu dữ liệu tùy chỉnh gọi là string.

Số học con trỏ (Pointer Arithmetic)

  • Số học con trỏ là khả năng thực hiện các phép toán trên các vị trí của bộ nhớ.

Bạn có thể sửa đổi mã của mình để in ra từng vị trí bộ nhớ trong chuỗi như sau:

// Prints a string's chars

#include <stdio.h>

int main(void)
{
    char *s = "HI!";
    printf("%c\n", s[0]);
    printf("%c\n", s[1]);
    printf("%c\n", s[2]);
}

Lưu ý rằng chúng ta đang in từng ký tự tại vị trí của s.

Hơn nữa, bạn có thể sửa đổi mã của mình như sau:

// Prints a string's chars via pointer arithmetic

#include <stdio.h>

int main(void)
{
    char *s = "HI!";
    printf("%c\n", *s);
    printf("%c\n", *(s + 1));
    printf("%c\n", *(s + 2));
}

Lưu ý rằng ký tự đầu tiên tại vị trí của s được in ra. Sau đó, ký tự tại vị trí s + 1 được in ra, và cứ tiếp tục như vậy.

Tương tự, hãy xem xét điều sau:

// Prints substrings via pointer arithmetic

#include <stdio.h>

int main(void)
{
    char *s = "HI!";
    printf("%s\n", s);
    printf("%s\n", s + 1);
    printf("%s\n", s + 2);
}

Lưu ý rằng mã này in ra các giá trị được lưu trữ tại các vị trí bộ nhớ khác nhau bắt đầu bằng s.

So sánh chuỗi (String Comparison)

  • Một chuỗi các ký tự chỉ đơn giản là một mảng các ký tự được xác định bởi vị trí của byte đầu tiên của nó.

Trước đó trong khóa học, chúng ta đã xem xét việc so sánh các số nguyên. Chúng ta có thể biểu diễn điều này trong mã bằng cách gõ code compare.c vào cửa sổ terminal như sau:

// Compares two integers

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // Get two integers
    int i = get_int("i: ");
    int j = get_int("j: ");

    // Compare integers
    if (i == j)
    {
        printf("Same\n");
    }
    else
    {
        printf("Different\n");
    }
}

Lưu ý rằng mã này lấy hai số nguyên từ người dùng và so sánh chúng.

  • Tuy nhiên, trong trường hợp chuỗi, người ta không thể so sánh hai chuỗi bằng toán tử ==.

  • Sử dụng toán tử == trong nỗ lực so sánh các chuỗi sẽ cố gắng so sánh các vị trí bộ nhớ của các chuỗi thay vì các ký tự bên trong chúng. Do đó, chúng tôi khuyên bạn nên sử dụng strcmp.

Để minh họa điều này, hãy sửa đổi mã của bạn như sau:

// Compares two strings' addresses

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // Get two strings
    char *s = get_string("s: ");
    char *t = get_string("t: ");

    // Compare strings' addresses
    if (s == t)
    {
        printf("Same\n");
    }
    else
    {
        printf("Different\n");
    }
}

Lưu ý rằng việc nhập HI! cho cả hai chuỗi vẫn dẫn đến kết quả là Different.

Tại sao những chuỗi này dường như khác nhau? Bạn có thể sử dụng hình ảnh sau để hình dung lý do tại sao:

  • Do đó, mã cho compare.c ở trên thực sự là đang cố gắng xem liệu các địa chỉ bộ nhớ có khác nhau hay không, chứ không phải bản thân các chuỗi.

Sử dụng strcmp, chúng ta có thể sửa mã của mình:

// Compares two strings using strcmp

#include <cs50.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // Get two strings
    char *s = get_string("s: ");
    char *t = get_string("t: ");

    // Compare strings
    if (strcmp(s, t) == 0)
    {
        printf("Same\n");
    }
    else
    {
        printf("Different\n");
    }
}

Lưu ý rằng strcmp có thể trả về 0 nếu các chuỗi giống nhau.

Để minh họa thêm về việc hai chuỗi này đang nằm ở hai vị trí, hãy sửa đổi mã của bạn như sau:

// Prints two strings

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // Get two strings
    char *s = get_string("s: ");
    char *t = get_string("t: ");

    // Print strings
    printf("%s\n", s);
    printf("%s\n", t);
}

Lưu ý cách chúng ta hiện có hai chuỗi riêng biệt được lưu trữ, có khả năng tại hai vị trí riêng biệt.

Bạn có thể thấy vị trí của hai chuỗi được lưu trữ này với một sửa đổi nhỏ:

// Prints two strings' addresses

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // Get two strings
    char *s = get_string("s: ");
    char *t = get_string("t: ");

    // Print strings' addresses
    printf("%p\n", s);
    printf("%p\n", t);
}

Lưu ý rằng %s đã được thay đổi thành %p trong câu lệnh in.

Sao chép và malloc

  • Một nhu cầu phổ biến trong lập trình là sao chép một chuỗi sang một chuỗi khác.

Trong cửa sổ terminal, gõ code copy.c và viết mã như sau:

// Capitalizes a string

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // Get a string
    string s = get_string("s: ");

    // Copy string's address
    string t = s;

    // Capitalize first letter in string
    t[0] = toupper(t[0]);

    // Print string twice
    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

Lưu ý rằng string t = s sao chép địa chỉ của s sang t. Điều này không đạt được điều chúng ta mong muốn. Chuỗi không được sao chép – chỉ có địa chỉ được sao chép. Hơn nữa, hãy lưu ý việc bao gồm ctype.h.

Bạn có thể hình dung đoạn mã trên như sau:

Lưu ý rằng st vẫn đang trỏ vào cùng các khối bộ nhớ. Đây không phải là một bản sao thực sự của một chuỗi. Thay vào đó, đây là hai con trỏ trỏ vào cùng một chuỗi.

Trước khi giải quyết thách thức này, điều quan trọng là đảm bảo rằng chúng ta không gặp lỗi segmentation fault thông qua mã của mình, nơi chúng ta cố gắng sao chép string s sang string t, trong khi string t không tồn tại. Chúng ta có thể sử dụng hàm strlen như sau để hỗ trợ việc đó:

// Capitalizes a string, checking length first

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // Get a string
    string s = get_string("s: ");

    // Copy string's address
    string t = s;

    // Capitalize first letter in string
    if (strlen(t) > 0)
    {
        t[0] = toupper(t[0]);
    }

    // Print string twice
    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

Lưu ý rằng strlen được sử dụng để đảm bảo string t tồn tại. Nếu nó không tồn tại, sẽ không có gì được sao chép.

Để có thể tạo một bản sao thực sự của chuỗi, chúng ta sẽ cần giới thiệu hai khối xây dựng mới. Đầu tiên, malloc cho phép bạn, lập trình viên, cấp phát một khối bộ nhớ có kích thước cụ thể. Thứ hai, free cho phép bạn bảo trình biên dịch giải phóng khối bộ nhớ mà bạn đã cấp phát trước đó.

Chúng ta có thể sửa đổi mã của mình để tạo một bản sao thực sự của chuỗi như sau:

// Capitalizes a copy of a string

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // Get a string
    char *s = get_string("s: ");

    // Allocate memory for another string
    char *t = malloc(strlen(s) + 1);

    // Copy string into memory, including '\0'
    for (int i = 0; i <= strlen(s); i++)
    {
        t[i] = s[i];
    }

    // Capitalize copy
    t[0] = toupper(t[0]);

    // Print strings
    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

Lưu ý rằng malloc(strlen(s) + 1) tạo ra một khối bộ nhớ có độ dài bằng độ dài của chuỗi s cộng thêm một. Điều này cho phép bao gồm ký tự null \0 trong chuỗi được sao chép cuối cùng của chúng ta. Sau đó, vòng lặp for đi qua chuỗi s và gán từng giá trị cho cùng vị trí đó trên chuỗi t.

Hóa ra mã của chúng ta không hiệu quả. Hãy sửa đổi mã của bạn như sau:

// Capitalizes a copy of a string, defining n in loop too

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // Get a string
    char *s = get_string("s: ");

    // Allocate memory for another string
    char *t = malloc(strlen(s) + 1);

    // Copy string into memory, including '\0'
    for (int i = 0, n = strlen(s); i <= n; i++)
    {
        t[i] = s[i];
    }

    // Capitalize copy
    t[0] = toupper(t[0]);

    // Print strings
    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

Lưu ý rằng n = strlen(s) hiện được định nghĩa ở phía bên trái của vòng lặp for. Tốt nhất là không nên gọi các hàm không cần thiết trong điều kiện ở giữa của vòng lặp for, vì nó sẽ chạy lặp đi lặp lại. Khi chuyển n = strlen(s) sang phía bên trái, hàm strlen chỉ chạy một lần.

Ngôn ngữ C có một hàm tích hợp sẵn để sao chép chuỗi gọi là strcpy. Nó có thể được triển khai như sau:

// Capitalizes a copy of a string using strcpy

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // Get a string
    char *s = get_string("s: ");

    // Allocate memory for another string
    char *t = malloc(strlen(s) + 1);

    // Copy string into memory
    strcpy(t, s);

    // Capitalize copy
    t[0] = toupper(t[0]);

    // Print strings
    printf("s: %s\n", s);
    printf("t: %s\n", t);
}

Lưu ý rằng strcpy thực hiện cùng một công việc mà vòng lặp for của chúng ta đã làm trước đó.

Cả get_stringmalloc đều trả về NULL, một giá trị đặc biệt trong bộ nhớ, trong trường hợp có sự cố xảy ra. Bạn có thể viết mã để kiểm tra điều kiện NULL này như sau:

// Capitalizes a copy of a string without memory errors

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // Get a string
    char *s = get_string("s: ");
    if (s == NULL)
    {
        return 1;
    }

    // Allocate memory for another string
    char *t = malloc(strlen(s) + 1);
    if (t == NULL)
    {
        return 1;
    }

    // Copy string into memory
    strcpy(t, s);

    // Capitalize copy
    if (strlen(t) > 0)
    {
        t[0] = toupper(t[0]);
    }

    // Print strings
    printf("s: %s\n", s);
    printf("t: %s\n", t);

    // Free memory
    free(t);
    return 0;
}

Lưu ý rằng nếu chuỗi nhận được có độ dài 0 hoặc malloc thất bại, NULL sẽ được trả về. Hơn nữa, hãy lưu ý rằng free cho máy tính biết bạn đã hoàn thành khối bộ nhớ mà bạn đã tạo thông qua malloc.

Valgrind

Valgrind là một công cụ có thể kiểm tra xem có các vấn đề liên quan đến bộ nhớ trong các chương trình của bạn khi bạn sử dụng malloc hay không. Cụ thể, nó kiểm tra xem bạn có free tất cả bộ nhớ mà bạn đã cấp phát hay không.

Hãy xem xét đoạn mã sau cho memory.c:

// Demonstrates memory errors via valgrind

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *x = malloc(3 * sizeof(int));
    x[1] = 72;
    x[2] = 73;
    x[3] = 33;
}

Lưu ý rằng việc chạy chương trình này không gây ra bất kỳ lỗi nào. Trong khi malloc được sử dụng để cấp phát đủ bộ nhớ cho một mảng, mã lại không free bộ nhớ đã cấp phát đó.

  • Nếu bạn gõ make memory rồi sau đó là valgrind ./memory, bạn sẽ nhận được một báo cáo từ valgrind cho biết bộ nhớ đã bị mất ở đâu do chương trình của bạn. Một lỗi mà valgrind tiết lộ là chúng ta đã cố gắng gán giá trị 33 tại vị trí thứ 4 của mảng, trong khi chúng ta chỉ cấp phát một mảng có kích thước 3. Một lỗi khác là chúng ta chưa bao giờ giải phóng x.

Bạn có thể sửa đổi mã của mình để giải phóng bộ nhớ của x như sau:

// Demonstrates memory errors via valgrind

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *x = malloc(3 * sizeof(int));
    x[1] = 72;
    x[2] = 73;
    x[3] = 33;
    free(x);
}

Lưu ý rằng việc chạy lại valgrind lúc này không dẫn đến rò rỉ bộ nhớ (memory leaks).

Giá trị rác (Garbage Values)

  • Khi bạn yêu cầu trình biên dịch cấp một khối bộ nhớ, không có gì đảm bảo rằng bộ nhớ này sẽ trống.

Rất có khả năng bộ nhớ bạn cấp phát đã được máy tính sử dụng trước đó. Do đó, bạn có thể thấy các giá trị rác (junk values hoặc garbage values). Đây là kết quả của việc bạn nhận được một khối bộ nhớ nhưng không khởi tạo nó. Ví dụ, hãy xem xét đoạn mã sau cho garbage.c:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int scores[1024];
    for (int i = 0; i < 1024; i++)
    {
        printf("%i\n", scores[i]);
    }
}

Lưu ý rằng việc chạy mã này sẽ cấp phát 1024 vị trí trong bộ nhớ cho mảng của bạn, nhưng vòng lặp for có khả năng sẽ cho thấy không phải tất cả các giá trị trong đó đều là 0. Luôn là một thực hành tốt nhất khi nhận thức được khả năng có các giá trị rác khi bạn không khởi tạo các khối bộ nhớ về một giá trị nào đó như không hoặc giá trị khác.

Pointer Fun with Binky

Hoán đổi (Swapping)

Trong thế giới thực, một nhu cầu phổ biến trong lập trình là hoán đổi hai giá trị. Đương nhiên, rất khó để hoán đổi hai biến mà không có một không gian lưu giữ tạm thời. Trong thực tế, bạn có thể gõ code swap.c và viết mã như sau để thấy điều này hoạt động:

// Fails to swap two integers

#include <stdio.h>

void swap(int a, int b);

int main(void)
{
    int x = 1;
    int y = 2;

    printf("x is %i, y is %i\n", x, y);
    swap(x, y);
    printf("x is %i, y is %i\n", x, y);
}

void swap(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
}

Lưu ý rằng mặc dù mã này chạy, nhưng nó không hoạt động. Các giá trị, ngay cả sau khi được gửi đến hàm swap, vẫn không hoán đổi. Tại sao?

  • Khi bạn truyền các giá trị vào một hàm, bạn chỉ đang cung cấp các bản sao. Phạm vi (scope) của xy bị giới hạn trong hàm main như mã hiện đang được viết. Nghĩa là, các giá trị của xy được tạo ra trong các dấu ngoặc nhọn {} của hàm main chỉ có phạm vi trong hàm main. Trong mã của chúng ta ở trên, xy đang được truyền bằng giá trị (pass by value).

Hãy xem xét hình ảnh sau:

Lưu ý rằng các biến toàn cục (global variables), mà chúng ta chưa sử dụng trong khóa học này, sống ở một nơi trong bộ nhớ. Các hàm khác nhau được lưu trữ trong stack ở một khu vực khác của bộ nhớ.

Bây giờ, hãy xem xét hình ảnh sau:

Lưu ý rằng mainswap có hai khung (frames) hoặc vùng bộ nhớ riêng biệt. Do đó, chúng ta không thể đơn giản là truyền các giá trị từ hàm này sang hàm khác để thay đổi chúng.

Sửa đổi mã của bạn như sau:

// Swaps two integers using pointers

#include <stdio.h>

void swap(int *a, int *b);

int main(void)
{
    int x = 1;
    int y = 2;

    printf("x is %i, y is %i\n", x, y);
    swap(&x, &y);
    printf("x is %i, y is %i\n", x, y);
}

void swap(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

Lưu ý rằng các biến không được truyền bằng giá trị mà bằng tham chiếu (pass by reference). Nghĩa là, các địa chỉ của ab được cung cấp cho hàm. Do đó, hàm swap có thể biết nơi thực hiện các thay đổi đối với ab thực sự từ hàm main.

Bạn có thể hình dung điều này như sau:

Tràn (Overflow)

  • Heap overflow (Tràn heap) là khi bạn làm tràn vùng heap, chạm vào các vùng bộ nhớ mà bạn không được phép.

  • Stack overflow (Tràn stack) là khi có quá nhiều hàm được gọi, làm tràn lượng bộ nhớ có sẵn.

  • Cả hai điều này đều được coi là buffer overflows (tràn bộ đệm).

scanf

  • Trong CS50, chúng tôi đã tạo ra các hàm như get_int để đơn giản hóa việc lấy đầu vào từ người dùng.

scanf là một hàm tích hợp sẵn có thể lấy đầu vào của người dùng.

Chúng ta có thể triển khai lại get_int khá dễ dàng bằng cách sử dụng scanf như sau:

// Gets an int from user using scanf

#include <stdio.h>

int main(void)
{
    int n;
    printf("n: ");
    scanf("%i", &n);
    printf("n: %i\n", n);
}

Lưu ý rằng giá trị của n được lưu trữ tại vị trí của n trong dòng scanf("%i", &n).

Tuy nhiên, việc cố gắng triển khai lại get_string là không hề dễ dàng. Hãy xem xét điều sau:

// Dangerously gets a string from user using scanf with array

#include <stdio.h>

int main(void)
{
    char s[4];
    printf("s: ");
    scanf("%s", s);
    printf("s: %s\n", s);
}

Lưu ý rằng không cần dấu & vì chuỗi rất đặc biệt. Tuy nhiên, chương trình này sẽ không hoạt động chính xác mỗi khi nó được chạy. Không có nơi nào trong chương trình này chúng ta cấp phát lượng bộ nhớ cần thiết cho chuỗi của mình. Thực vậy, chúng ta không biết người dùng có thể nhập một chuỗi dài bao nhiêu! Hơn nữa, chúng ta không biết những giá trị rác nào có thể tồn tại ở vị trí bộ nhớ đó.

Hơn nữa, mã của bạn có thể được sửa đổi như sau. Tuy nhiên, chúng ta phải cấp phát trước một lượng bộ nhớ nhất định cho một chuỗi:

// Using malloc

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char *s = malloc(4);
    if (s == NULL)
    {
        return 1;
    }
    printf("s: ");
    scanf("%s", s);
    printf("s: %s\n", s);
    free(s);
    return 0;
}

Lưu ý rằng nếu một chuỗi dài bốn byte được cung cấp, bạn có thể sẽ gặp lỗi.

Đơn giản hóa mã của chúng ta như sau, chúng ta có thể hiểu sâu hơn về vấn đề cấp phát trước thiết yếu này:

#include <stdio.h>

int main(void)
{
    char s[4];
    printf("s: ");
    scanf("%s", s);
    printf("s: %s\n", s);
}

Lưu ý rằng nếu chúng ta cấp phát trước một mảng có kích thước 4, chúng ta có thể nhập cat và chương trình hoạt động. Tuy nhiên, một chuỗi lớn hơn mức này có thể tạo ra lỗi.

  • Đôi khi, trình biên dịch hoặc hệ thống chạy nó có thể cấp phát nhiều bộ nhớ hơn mức chúng ta chỉ định. Tuy nhiên, về cơ bản, đoạn mã trên là không an toàn. Chúng ta không thể tin tưởng rằng người dùng sẽ nhập một chuỗi vừa với bộ nhớ đã được cấp phát trước của chúng ta.

Nhập/Xuất Tệp (File I/O)

Bạn có thể đọc từ các tệp và thao tác trên đó. Mặc dù chủ đề này sẽ được thảo luận thêm trong một tuần tới, hãy xem xét đoạn mã sau cho phonebook.c:

// Saves names and numbers to a CSV file

#include <cs50.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // Open CSV file
    FILE *file = fopen("phonebook.csv", "a");

    // Get name and number
    char *name = get_string("Name: ");
    char *number = get_string("Number: ");

    // Print to file
    fprintf(file, "%s,%s\n", name, number);

    // Close file
    fclose(file);
}

Lưu ý rằng mã này sử dụng các con trỏ để truy cập tệp.

  • Bạn có thể tạo một tệp gọi là phonebook.csv trước khi chạy mã trên hoặc tải xuống phonebook.csv. Sau khi chạy chương trình trên và nhập tên cùng số điện thoại, bạn sẽ nhận thấy dữ liệu này vẫn còn tồn tại trong tệp CSV của mình.

Nếu chúng ta muốn đảm bảo rằng phonebook.csv tồn tại trước khi chạy chương trình, chúng ta có thể sửa đổi mã của mình như sau:

// Saves names and numbers to a CSV file

#include <cs50.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    // Open CSV file
    FILE *file = fopen("phonebook.csv", "a");
    if (!file)
    {
        return 1;
    }

    // Get name and number
    char *name = get_string("Name: ");
    char *number = get_string("Number: ");

    // Print to file
    fprintf(file, "%s,%s\n", name, number);

    // Close file
    fclose(file);
}

Lưu ý rằng chương trình này bảo vệ chống lại một con trỏ NULL bằng cách gọi return 1.

Chúng ta có thể triển khai chương trình sao chép của riêng mình bằng cách gõ code cp.c và viết mã như sau:

// Copies a file

#include <stdint.h>
#include <stdio.h>

typedef uint8_t BYTE;

int main(int argc, char *argv[])
{
    FILE *src = fopen(argv[1], "rb");
    FILE *dst = fopen(argv[2], "wb");

    BYTE b;

    while (fread(&b, sizeof(b), 1, src) != 0)
    {
        fwrite(&b, sizeof(b), 1, dst);
    }

    fclose(dst);
    fclose(src);
}

Lưu ý rằng tệp này tạo ra kiểu dữ liệu của riêng chúng ta gọi là BYTE, có kích thước của một uint8_t. Sau đó, tệp đọc một BYTE và ghi nó vào một tệp.

  • BMP cũng là các tập hợp dữ liệu mà chúng ta có thể kiểm tra và thao tác. Tuần này, bạn sẽ làm chính xác điều đó trong các bài tập của mình!

Tổng kết

Trong bài học này, bạn đã học về con trỏ, thứ cung cấp cho bạn khả năng truy cập và thao tác dữ liệu tại các vị trí bộ nhớ cụ thể. Cụ thể, chúng ta đã đi sâu vào…

  • Pixel art

  • Hệ thập lục phân

  • Bộ nhớ

  • Con trỏ

  • Chuỗi

  • Số học con trỏ

  • So sánh chuỗi

  • Sao chép

  • malloc và Valgrind

  • Giá trị rác

  • Hoán đổi

  • Tràn (Overflow)

  • scanf

  • Nhập/Xuất Tệp (File I/O)

Hẹn gặp lại bạn vào lần tới!