Array, string và slice trong Go
Array, string và slice là các kiểu dữ liệu phổ biến trong ngôn ngữ Go, giữa chúng có sự liên quan mật thiết với nhau. Ba kiểu dữ liệu đó có cùng cấu trúc vùng nhớ lưu trữ bên dưới, và chỉ có những hành vi thể hiện ra bên ngoài khác nhau và tùy thuộc vào ràng buộc ngữ nghĩa.
Bài viết sau sẽ trình bày chi tiết về ba kiểu dữ liệu này, bao gồm cấu trúc vùng nhớ, các hàm thao tác,... nội dung tham khảo từ Advanced Go Book do team chúng tôi biên soạn.
1. Array
Trong ngôn ngữ Go, array là một kiểu giá trị. Mặc dù những phần tử của array có thể được chỉnh sửa, phép gán của array hoặc khi truyền array như là một tham số của hàm thì chúng sẽ được xử lý toàn bộ, có thể hiểu là khi đó chúng được sao chép lại toàn bộ thành một bản sao rồi mới xử lý trên bản sao đó (khác với kiểu truyền tham khảo).
Một array là một chuỗi độ dài cố định của các phần tử có cùng kiểu dữ liệu nào đó, một array có thể bao gồm không hoặc nhiều phần tử. Độ dài của array là một phần thông tin được chứa trong nó, các array có độ dài khác nhau hoặc kiểu phần tử bên trong khác nhau được xem là các kiểu dữ liệu khác nhau, và không được phép gán cho nhau, vì những ràng buộc đó nên array ít khi được sử dụng trong Go.
Cách định nghĩa một array:
Khi biến array được gán hoặc truyền, thì toàn bộ array sẽ được sao chép. Nếu kích thước của array lớn, thì phép gán array sẽ đi kèm overhead (tổn phí) lớn. Để tránh việc overhead trong việc sao chép array, bạn có thể truyền con trỏ của array.
Lưu ý: con trỏ array thì không phải là một array.
Hàm len có thể dùng để lấy thông tin về độ dài của array, và hàm cap sẽ tính toán độ dài tối đa của array. Nhưng trong kiểu array cả hai hàm này sẽ cùng trả về một giá trị giống nhau (điều này khác với slice).
Chúng ta có thể dùng vòng lặp for dể duyệt qua các phần tử của array. Sau đây là những cách thường dùng để duyệt qua một array:
Dùng for range là cách tốt nhất để duyệt qua các phần tử trong array, bởi vì cách này sẽ đảm các việc truy xuất sẽ không vượt quá giới hạn của array.
Các phần tử của array không nhất thiết là kiểu số học, nên cũng có thể là string, struct, function, interface, channel, v,v...
Chúng ta cũng có thể định nghĩa một array rỗng:
Một array có chiều dài 0 thì không chiếm không gian lưu trữ, vì thế chúng thường được dùng kết hợp với channel để đồng bộ các goroutine.
2. String
String cũng là một array của các byte dữ liệu, nhưng khác với array những phần tử của string là immutable.
Cấu trúc reflect.StringHeader được dùng để biểu diễn string :
Một string là một cấu trúc, do đó phép gán string thực chất là việc sao chép cấu trúc reflect.StringHeader, và không gây ra việc sao chép bên dưới phần dữ liệu.
Cấu trúc vùng nhớ tương ứng với string “Hello World” là:
Có thể thấy rằng bên dưới string “Hello World” là một array như sau:
Mặc dù string không phải là slice nhưng nó cũng hỗ trợ thao tác slicing (cắt). Một vài phần của vùng nhớ cũng được truy cập thông qua slice tại một số nơi khác nhau:
Tương tự như array, string cũng có một hàm built-in là len dùng để trả về chiều dài của string, ngoài ra bạn có thể dùng reflect.StringHeader để truy xuất chiều dài của string theo cách như sau:
3. Slice
Slice thì phức tạp hơn, có thể hiểu đơn giản nó là một view tới array, cũng có thể xem slice trong Go tương tự với fat pointer trong C theo bài viết sau.
Cấu trúc reflect.SliceHeader được dùng để biểu diễn slice.
Ngoài Data và Len, slice có thêm thuộc tính Cap chỉ ra kích thước tối đa mà vùng nhớ trỏ tới slice được cấp phát.
Hình bên dưới sẽ miêu tả slice x := []int{2,3,5,7,11} và slice y := x[1:3]
Các cách định nghĩa slice:
Các tác vụ cơ bản trong slice bao gồm:
- Thêm phần tử vào slice
- Xóa phần tử trong slice
- Duyệt qua các phần tử của slice
3.1. Thêm phần tử vào slice
Hàm buit-in append được dùng để thêm n phần tử vào cuối cùng của slice:
Về cơ bản, hàm này được xây dựng tương tự như sau:
Trong trường hợp slice ban đầu không đủ sức chứa khi thêm vào phần tử, hàm append sẽ hiện thực cấp phát lại vùng nhớ có kích thước gấp đôi vùng nhớ cũ và sao chép dữ liệu sang.
Ví dụ bên dưới cho thấy giá trị cap tăng gấp 2 khi thực thi hàm append vượt quá kích thước ban đầu.
Bên cạnh thêm phần tử vào cuối slice, chúng ta cũng có thể thêm phần tử vào đầu slice:
Thêm phần tử vào đầu slice sẽ gây ra việc cấp phát lại vùng nhớ và làm những phần tử đang tồn tại trong slice sẽ được sao chép một lần nữa. Do đó, hiệu suất của việc thêm phần tử vào đầu slice sẽ thấp hơn thêm phần tử vào cuối slice.
Ở trên, hàm append sẽ trả về một slice mới, chúng ta có thể kết hợp nhiều hàm append để chèn một vài phần tử vào giữa slice :
Bạn cũng có thể sử dụng hàm copy và append kết hợp với nhau để tránh việc khởi tạo những slice tạm thời như vậy:
Chúng ta cũng có thể chèn nhiều phần tử vào vị trí chính giữa bằng việc kết hợp hàm copy và append :
3.2. Xóa những phần tử trong slice
Có ba trường hợp xóa các phần tử:
- Ở đầu
- Ở giữa
- Ở cuối
Trong đó xóa phần tử ở cuối là nhanh nhất
Xóa phần tử ở đầu thì thực chất là di chuyển con trỏ dữ liệu về sau
Khi xóa phần tử ở giữa, bạn cần dịch chuyển những phần tử ở phía sau lên trước, điều đó có thể được thực hiện như sau
3.3. Các hàm thao tác với slice
Hàm TrimSpace sau sẽ xóa đi các khoảng trắng. Hiện thực hàm này với độ phức tạp O(n) để đạt được sự hiệu quả và đơn giản.
Thực tế, những giải thuật tương tự để xóa những phần tử trong slice khi chúng thỏa một điều kiện nào đó cũng có thể được xây dựng theo cách trên.
Lưu ý: hàm append() không cấp phát lại vùng nhớ khi chưa đạt tới sức chứa tối đa Cap.
Hàm Filter sẽ lọc các phần tử thỏa điều kiện trong slice.
Điểm chính của những tác vụ làm việc hiệu quả trên slice là hạn chế việc phải cấp lại vùng nhớ, cố gắng để hàm append sẽ không đạt tới cap sức chứa của slice, là giảm số lần cấp phát vùng nhớ và giảm kích thước vùng nhớ cấp phát tại mọi thời điểm.
3.4. Tránh gây ra memory leak trên slice
Xóa những phần tử trong slice thực chất sẽ không thay đổi vùng nhớ bên dưới slice, mà là thay đổi các tham số như Data, Len. Vùng nhớ bên dưới vẫn chưa được giải phóng cho đến khi nào không còn được tham chiếu bởi slice nữa.
Ví dụ là hàm FindPhoneNumber sẽ tải toàn bộ file vào bộ nhớ, và sau đó tìm kiếm số điện thoại đầu tiên xuất hiện trong file, và kết quả cuối cùng sẽ trả về một array.
Mã nguồn trên sẽ trả về một mảng các byte trỏ tới toàn bộ file. Bởi vì slice tham khảo tới toàn bộ array gốc, cơ chế tự động thu gom rác không thể giải phóng không gian bên dưới array trong thời gian đó. Một yêu cầu kết quả nhỏ, nhưng phải lưu trữ toàn bộ dữ liệu trong một thời gian dài. Mặc dù nó không phải là memory leak trong ngữ cảnh truyền thống, nó có thể làm chậm hiệu suất của toàn hệ thống.
Để khắc phục vấn đề này, bạn có thể sao chép dữ liệu cần thiết thành một slice mới nhờ hàm append, biến tham khảo tới slice cũ sẽ được giải phóng khi ra khỏi hàm.
Như đã đề cập, việc xóa phần tử trong slice không đi đôi với việc vùng nhớ đó được giải phóng bởi trình dọn dẹp Garbage Collector (GC).
Cách giải quyết là gán phần tử cần xóa bằng nil để đảm bảo vùng nhớ đó được GC tìm thấy và giải phóng.
Tham khảo: https://zalopay-oss.github.io/go-advanced/ch1-basic/ch1-03-array-string-and-slice.html