Tìm hiểu về Unsafe Swift: sử dụng con trỏ trong Swift

I. Giới thiệu

Như chúng ta đã biết, Swift là một ngôn ngữ mới được phát triển bới Apple. Trước đây khi code bằng Objective-C, chúng ta rất hay gặp phải trường hợp app crash khi sử dụng một object chưa được khởi tạo. Đối với Swift thì khác, đây là một ngôn ngữ “an toàn” - Mặc định Swift chắc chắn rằng chúng ta không thể truy cập trực tiếp vào memory, mọi thứ(instance, variable,…) phải được khởi tạo trước khi chúng ta sử dụng. Mặc dù không được khuyến khích, nhưng chúng ta hoàn toàn có thể truy cập trực tiếp vào memory trong swift. Trong bài này, tôi xin giới thiệu đến các bạn cách sử dụng pointer và tương tác với ngôn ngữ C để truy cập vào memory trong Swif.

II. Sử dụng Unsafe Swift

Unsafe Swift làm việc trực tiếp trên memory. Về cơ bản, chúng ta có thể hình dung memory như 1 dãy gồm rất nhiều ô trống (hàng tỉ ô). Mỗi ô chứa đều được đánh địa chỉ (địa chỉ mỗi ô nhớ là duy nhất). Ô nhớ nhỏ nhất có thể đánh địa chỉ là byte, mỗi byte gồm 8 bit và có thể chứa được giá trị trong khoảng 0-255. Thông thường, mỗi một chữ được lưu trong nhiều byte. Ví dụ, trong hệ thống 64-bit, mỗi một chữ được lưu trữ trong 8 byte.

1. Memory Layout

Để biết được Swift sử dụng memory để lưu các số, string, instance,… như thế nào, chúng ta sử dụng class MemoryLayout. Các bạn mở Playground trong Xcode rồi gõ thử code như hình sau:

Các bạn có thể thấy trên hình, chúng ta sử dụng generic enum MemoryLayout để lấy giá trị size, alignment và stride của số Int. Số Int ở đây được mặc định là Int64, vì vậy size, alignment và stride của số Int đều là 8 (8 byte tương đương 64 bit). Ví dụ chúng ta có dòng code sau:

let intNumber: Int = 2

size của số Int là 8, tức là intNumber được lưu trữ trong 8 byte alignment của số Int cũng là 8, là một số chẵn, nên byte đầu tiên trong chuỗi 8 byte lưu trữ intNumber phải có địa chỉ là số chẵn ( ví dụ số intNumber có thể được lưu trữ từ địa chỉ 100 -> 107 chẳng hạn) stride của số Int là 8, tức là con trỏ chỉ sang số tiếp theo sẽ có bước dịch chuyển là 8. Để cho dễ hiểu, giả sử chúng ta có 3 số Int, 3 số này được lưu trữ trong 24 byte liên tiếp 100 -> 123. thì chúng ta sẽ có 100 -> 107 lưu trữ số thứ 1, 108 -> 115 lưu trữ số thứ 2 và 116 -> 123 lưu trữ số thứ 3. Để chuyển con trỏ từ số thứ 1 sang số thứ 2, chúng ta sẽ chuyển con trỏ này từ ô 100 lên ô 108 (address + stride)

Bên trên là memory layout của Int, 1 kiểu số cơ bản. Chúng ta tiếp tục thử với 1 kiểu cấu trúc khác của Swift. Các bạn thêm code vào playground như hình sau:

Chúng ta có thể thấy, size của SampleStruct là 5 bởi vì struct này chứa 1 số Int32 (4 byte) và 1 Bool (1 byte). Vì Struct này có chứa số Int32 có alignment là 4, nên alignment của struct này là 4. Tuy nhiên stride của struct này lại là 8 bởi vì struct này có size là 5 mà alignment là 4, vì thế Swift sắp xếp mỗi struct vào 8 byte ô nhớ.

Cuối cùng, chúng ta thử kiểm tra memory layout của một class. Các bạn thêm code vào playground như trong hình sau:

Như các bạn thấy kết quả trong playground bên trên, không quan trọng bên trong class có bao nhiêu property cần bao nhiêu ô nhớ, size, alignment và stride của class luôn bằng 8. Bởi vì class là dạng reference, nên class luôn lấy giá trị size của reference: là 8 byte

Để tìm hiểu cụ thể hơn về memory layout, các bạn có thể tham khảo tại đây

2. Pointer

Khi sử dụng các hàm Swift thông thường, chúng ta ít khi làm việc trực tiếp với pointer. Các method làm việc trực tiếp với pointer đều có tiền tố UnSafe để chúng ta biết rằng chúng ta đang làm việc trực tiếp với memory, compiler sẽ không thể kiểm tra giúp chúng ta các lỗi mà có thể gặp phải trong quá trình runtime.

Trong ngôn ngữ C, chúng ta làm việc với pointer thông qua ký tự "*", chúng ta có thể sử dụng chung loại pointer này cho bất kể cấu trúc nào, Tuy nhiên ngôn ngữ Swift lại không như vậy, chúng ta không có một pointer sử dụng chung, mà chúng ta có hàng tá pointer để sử dụng với nhiều mục đích khác nhau. Các pointer có định dạng như sau:

Unsafe[Mutable][Raw][Buffer]Pointer<T>

Trong đó:

  • Unsafe: Tiền tố tất cả các pointer đều phải có
  • Mutable: Chúng ta có thể ghi vào các mutable pointer
  • Raw: Raw pointer có nghĩa là pointer trỏ đến 1 blog các ô nhớ
  • Buffer: Buffer pointer có nghĩa là pointer này hoạt động như một collection
  • <T>: generic này nghĩa là pointer có thể sử dụng cho nhiều kiểu khác nhau

Để tìm hiểu xâu hơn, chúng ta sẽ đi vào một ví dụ cụ thể. Các bạn gõ đoạn code vào trong playground như hình sau và quan sát out put của playground:

Các bạn có thể theo dõi luôn output của playground trong hình bên:

  • 1: khởi tạo các số Int
  • 2: Tạo một scope do {...} để chạy code
  • 3: Khởi tạo pointer: ở đây chúng ta khởi tạo Mutable - Raw pointer gồm 16(byteCount) và alignment là 8
  • 4: block code bên trong defer sẽ được chạy khi chúng ta kết thúc đoạn code trong scope do {...}. Trong đoạn code này, chúng ta sẽ destroy và giải phóng pointer để tránh memory leak
  • 5: Chúng ta lần lượt làm từng việc: ghi số Int 42 vào byte đầu tiên của pointer, dịch chuyển(stride) pointer đến byte tiếp theo(byte thứ 9) và ghi số 6 vào byte này. Hàm load() để chúng ta đọc dữ liệu của pointer tại byte này
  • 6: Chúng ta tạo vòng for để in ra các giá trị trong các byte chúng ta đã lưu vào trong ô nhớ

Các bạn hãy để ý vào output của playground, chúng ta có thể thấy ngoại trừ byte đầu tiên (byte 0) và byte số 9 (byte 8) là có giá trị, các byte khác đều có giá trị là 0. Ở đây pointer chỉ có thể lưu trữ được 2 số Int trong mỗi 8 byte chứ không phải lưu 16 số. Các byte có giá trị 0 sẽ có giá trị khi số Int của chúng ta có giá trị lớn. Hãy thử thay số 42 bằng 1 số khác lớn hơn, 10000000 chẳng hạn, các bạn sẽ thấy các byte tiếp theo sẽ lần lượt được gán giá trị (giá trị trong mỗi byte chỉ có thể nhận từ 0 - 255)

3. Lấy các byte của instance

Trong ví dụ với pointer bên trên, chúng ta đã lấy được ra các byte của pointer và in giá trị mỗi byte ra console log. Bây giờ, chúng ta hãy thử in giá trị các byte của 1 instance dạng Struct. Chúng ta thêm đoạn code như hình sau vào playground:

Trong đoạn code bên trên, chúng ta tạo 1 instance của struct SampleStruct. Hàm withUnsafeBytes cung cấp cho chúng ta một UnsafeRawBufferPointer của instance sampleStruct để chúng ta sử dụng bên trong block. Các bạn có thể thấy giá trị từng byte của instance sampleStruct trong console log của playground. Ở đây 4 byte đầu tiên được sử dụng để lưu trữ giá trị cho property Int32, byte cuối cùng để lưu giá trị cho boolean property.

4. Các quy tắc cần nhớ khi sử dụng Unsafe pointer

a. Không trả về pointer

Ví dụ đoạn code sau:

do {
   var sampleStruct = SampleStruct(number: 25, flag: true)
   
   let bytes = withUnsafeBytes(of: &sampleStruct) { bytes in
       return bytes
   }
   
   print(bytes)
}

Bên trên, chúng ta lấy pointer trả về của hàm withUnsafeBytes() và in pointer này ra. Tuy nhiên chúng ta không được lấy pointer này, sử dụng pointer này không gây ra lỗi nhưng sẽ trả về những giá trị không như chúng ta mong muốn. Chúng ta có thể viết lại đoạn code trên như sau:

do {
   var sampleStruct = SampleStruct(number: 25, flag: true)
   
   withUnsafeBytes(of: &sampleStruct) { bytes in
       print(bytes)
   }
}

b. Chỉ sử dụng(bind) một kiểu dữ liệu trong 1 thời điểm

Xét ví dụ sau:

do {
    let count = 3
    let stride = MemoryLayout<Int16>.stride
    let alignment = MemoryLayout<Int16>.alignment
    let byteCount =  count * stride
    
    let pointer = UnsafeMutableRawPointer.allocate(bytes: byteCount, alignedTo: alignment)
    
    let typedPointer1 = pointer.bindMemory(to: UInt16.self, capacity: count)
    let typedPointer2 = pointer.bindMemory(to: Bool.self, capacity: count * 2)
}

Bên trên, chúng ta sử dụng hàm bindMemory() với kiểu UInt16 cho pointer để khởi tạo typedPointer1. Tuy nhiên chúng ta không được sử dụng bindMemory() với kiểu Bool để khởi tạo typedPointer2, vì quy tắc chỉ bind một kiểu dữ liệu. Chúng ta có thể viết lại đoạn code bên trên như sau:

do {
    let count = 3
    let stride = MemoryLayout<Int16>.stride
    let alignment = MemoryLayout<Int16>.alignment
    let byteCount =  count * stride
    
    let pointer = UnsafeMutableRawPointer.allocate(bytes: byteCount, alignedTo: alignment)
    
    let typedPointer1 = pointer.bindMemory(to: UInt16.self, capacity: count)
    
    typedPointer1.withMemoryRebound(to: Bool.self, capacity: count * 2) {
        (boolPointer: UnsafeMutablePointer<Bool>) in
        print(boolPointer.pointee)
    }
}

Việc làm bên trên cũng tương đương việc khởi tạo typedPointer2, tuy nhiên chúng ta không tạo typedPointer2, mà chúng ta sử dụng luôn bên trong block của hàm withMemoryRebound(). Bởi vì theo nguyên tắc số 1 bên trên, không trả về pointer

c: Không được truy cập vào ô nhớ không thuộc pointer

Xét ví dụ sau:

do {
    let count = 3
    let stride = MemoryLayout<Int16>.stride
    let alignment = MemoryLayout<Int16>.alignment
    let byteCount =  count * stride
    
    let pointer = UnsafeMutableRawPointer.allocate(bytes: byteCount, alignedTo: alignment)
    let bufferPointer = UnsafeRawBufferPointer(start: pointer, count: byteCount + 1)
    
    for byte in bufferPointer {
        print(byte)
    }
}

Trong code bên trên, ban đầu chúng ta khởi tạo pointer với 6 byte (byteCount). Tuy nhiên sau đó chúng ta lại khởi tạo pointer bufferPointer với 7byte (byteCount + 1). Các bạn hãy tưởng tượng như chúng ta khởi tạo mảng với 5 phần tử, từ array[0] đến array[4], nhưng rồi chúng ta lại truy cập vào index array[5]. Việc truy cập vào array sẽ gây ra lỗi và chúng ta có thể fix bug. Tuy nhiên truy cập vào byte không phải của pointer sẽ không gây ra lỗi, code vẫn chạy và chúng ta không biết lỗi từ đâu ra 😄

III. Tổng kết

Trong bài viết này tôi đã giới thiệu đến các bạn một số khái niệm về memory layout và cách để chúng ta sử dụng pointer trong Swift. Thông thường trong ngôn ngữ Swift chúng ta rất hiếm khi sử dụng pointer, tuy nhiên hiếm sử dụng không có nghĩa là không bao giờ động tới. Sử dụng pointer không đúng cách có thể rất nguy hiểm và có thể gây ra các lỗi khó kiểm soát. Hi vọng bài viết này có thể giúp ích cho các bạn. Cuối cùng, xin cảm ơn các bạn đã theo dõi bài viết này. Have a nice day ^_^!


All Rights Reserved