Skip to content

string

string là một kiểu dữ liệu cơ bản rất phổ biến trong Go, cũng là kiểu dữ liệu đầu tiên tôi tiếp xúc trong ngôn ngữ Go:

go
package main

import "fmt"

func main() {
  fmt.Println("hello,world!")
}

Tin rằng đoạn code này hầu hết mọi người đều đã từng gõ khi mới tiếp xúc với Go. Trong builtin/builtin.go có mô tả đơn giản về string:

go
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

Từ đoạn văn trên có thể rút ra các thông tin sau:

  • string là tập hợp các byte 8 bit
  • Kiểu string thường là mã hóa UTF-8
  • string có thể rỗng, nhưng không thể là nil
  • string là không thể thay đổi

Những đặc điểm này đối với người thường xuyên sử dụng Go hẳn đã rất quen thuộc, vậy dưới đây hãy xem những điều khác biệt.

Cấu trúc

Trong Go, string được biểu diễn bởi cấu trúc runtime.stringStruct trong runtime, nhưng nó không được phơi bày ra ngoài. Thay vào đó có thể sử dụng reflect.StringHeader.

TIP

Mặc dù StringHeader đã bị loại bỏ trong phiên bản go1.21, nhưng nó确实 rất trực quan, nội dung dưới đây vẫn sẽ sử dụng nó để trình bày, không ảnh hưởng đến việc hiểu, chi tiết xem Issues · golang/go (github.com).

go
// runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}

// reflect/value.go
type StringHeader struct {
  Data uintptr
  Len  int
}

Các trường trong đó được giải thích như sau:

  • Data, là một con trỏ trỏ đến địa chỉ bắt đầu của bộ nhớ string
  • Len, số byte của string

Dưới đây là một ví dụ truy cập địa chỉ string thông qua con trỏ unsafe:

go
func main() {
  str := "hello,world!"
  h := *((*reflect.StringHeader)(unsafe.Pointer(&str)))
  for i := 0; i < h.Len; i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(unsafe.Pointer(h.Data), uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

Nhưng Go hiện tại khuyến nghị sử dụng unsafe.StringData để thay thế:

go
func main() {
  str := "hello,world!"
  ptr := unsafe.Pointer(unsafe.StringData(str))
  for i := 0; i < len(str); i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(ptr, uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

Cả hai đều cho kết quả như nhau:

h e l l o , w o r l d !

String về bản chất là một vùng bộ nhớ liên tục, mỗi địa chỉ đều lưu trữ một byte, nói cách khác là một mảng byte. Kết quả thu được thông qua hàm len là số lượng byte, không phải số lượng ký tự trong string, đặc biệt khi ký tự trong string là ký tự không phải ASCII.

Bản thân string chỉ chiếm rất ít bộ nhớ tức là một con trỏ trỏ đến dữ liệu thực, điều này khiến chi phí truyền string rất thấp. Cá nhân cho rằng, do chỉ giữ một tham chiếu bộ nhớ, nếu có thể bị sửa đổi tùy ý thì sau này rất khó biết liệu dữ liệu hướng đến ban đầu có còn là dữ liệu mong muốn hay không (hoặc sử dụng reflection hoặc sử dụng gói unsafe), trừ khi người sử dụng dữ liệu cũ sau khi sử dụng xong永远 không cần string này nữa. Một ưu điểm khác là an toàn đồng thời bẩm sinh, bất kỳ ai trong tình huống thông thường đều không thể sửa đổi nó.

Nối chuỗi

Cú pháp nối string như dưới đây, trực tiếp sử dụng toán tử + để nối:

go
var (
    hello = "hello"
    dot   = ","
    world = "world"
    last  = "!"
)
str := hello + dot + world + last

Thao tác nối được hoàn thành bởi hàm runtime.concatstrings trong runtime, nếu là nối literal như dưới đây, compiler sẽ trực tiếp suy ra kết quả:

go
str := "hello" + "," + "world" + "!"
_ = str

Thông qua xuất ra code assembly có thể biết kết quả, một phần như dưới đây:

LEAQ    go:string."hello,world!"(SB), AX
MOVQ    AX, main.str(SP)

Rất rõ ràng là compiler trực tiếp coi nó như một string hoàn chỉnh, giá trị của nó đã được xác định trong thời gian biên dịch, sẽ không do runtime.concatstrings nối trong runtime, chỉ khi nối các biến string mới được hoàn thành trong runtime, chữ ký hàm như sau, nó nhận một mảng byte và một slice string:

go
func concatstrings(buf *tmpBuf, a []string) string

Khi số biến string nối nhỏ hơn 5, sẽ sử dụng các hàm dưới đây để thay thế (cá nhân đoán: do tham số và biến ẩn được truyền, chúng đều tồn tại trên stack, so với slice được tạo trong runtime thì tốt hơn cho GC?), mặc dù cuối cùng vẫn do concatstrings hoàn thành việc nối:

go
func concatstring2(buf *tmpBuf, a0, a1 string) string {
  return concatstrings(buf, []string{a0, a1})
}

func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
  return concatstrings(buf, []string{a0, a1, a2})
}

func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3})
}

func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}

Dưới đây hãy xem hàm concatstrings làm những gì:

go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    // Bỏ qua nếu độ dài bằng 0
    if n == 0 {
      continue
    }
    // Tràn số khi tính toán
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    // Đếm số lượng
    count++
    idx = i
  }
  // Trả về chuỗi rỗng nếu không có string nào
  if count == 0 {
    return ""
  }

  // Trực tiếp trả về nếu chỉ có một string
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  // Cấp phát bộ nhớ cho string mới
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
    // Sao chép
    copy(b, x)
    // Cắt
    b = b[len(x):]
  }
  return s
}

Đầu tiên là thống kê tổng độ dài và số lượng của các string cần nối, sau đó phân phối bộ nhớ dựa trên tổng độ dài, hàm rawstringtmp sẽ trả về một string s và slice byte b, mặc dù độ dài của chúng được xác định nhưng chúng không có nội dung gì, vì về bản chất chúng là hai con trỏ trỏ đến địa chỉ bộ nhớ mới, code phân phối bộ nhớ như sau:

go
func rawstring(size int) (s string, b []byte) {
    // Không chỉ định kiểu
  p := mallocgc(uintptr(size), nil, false)
    // Mặc dù đã phân phối bộ nhớ nhưng trên đó không có gì
  return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

String s được trả về là để tiện biểu diễn, slice byte b là để tiện sửa đổi string, cả hai đều trỏ đến cùng một địa chỉ bộ nhớ.

go
for _, x := range a {
    // Sao chép
    copy(b, x)
    // Cắt
    b = b[len(x):]
}

Hàm copy gọi runtime.slicecopy trong runtime, công việc mà nó làm là trực tiếp sao chép bộ nhớ của src đến địa chỉ dst, sau khi sao chép xong tất cả các string thì quá trình nối cũng kết thúc. Nếu string được sao chép rất lớn, quá trình này sẽ tiêu tốn hiệu năng đáng kể.

Chuyển đổi

Như đã đề cập trước đó, bản thân string không thể sửa đổi, nếu cố sửa đổi thì ngay cả biên dịch cũng không qua được, Go sẽ báo lỗi như sau:

go
str := "hello" + "," + "world" + "!"
str[0] = '1'
cannot assign to string (neither addressable nor a map index expression)

Muốn sửa đổi string thì cần chuyển đổi kiểu của nó sang slice byte []byte trước, sử dụng rất đơn giản:

go
bs := []byte(str)

Bên trong nó gọi hàm runtime.stringtoslicebyte, logic của nó cũng rất đơn giản, code như sau:

go
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
  var b []byte
  if buf != nil && len(s) <= len(buf) {
    *buf = tmpBuf{}
    b = buf[:len(s)]
  } else {
    b = rawbyteslice(len(s))
  }
  copy(b, s)
  return b
}

Nếu độ dài string nhỏ hơn độ dài buffer thì trực tiếp trả về slice byte của buffer, như vậy có thể tiết kiệm bộ nhớ khi chuyển đổi string nhỏ. Nếu không thì sẽ mở một vùng bộ nhớ tương đương với độ dài string, sau đó sao chép string vào địa chỉ bộ nhớ mới, trong đó hàm rawbyteslice(len(s)) làm việc tương tự như hàm rawstring trước đó, đều là phân phối bộ nhớ.

Tương tự, slice byte cũng có thể dễ dàng chuyển đổi sang string về mặt cú pháp:

go
str := string([]byte{'h','e','l','l','o'})

Bên trong nó gọi hàm runtime.slicebytetostring, cũng rất dễ hiểu, code như sau:

go
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
  if n == 0 {
    return ""
  }

  if n == 1 {
    p := unsafe.Pointer(&staticuint64s[*ptr])
    if goarch.BigEndian {
      p = add(p, 7)
    }
    return unsafe.String((*byte)(p), 1)
  }

  var p unsafe.Pointer
  if buf != nil && n <= len(buf) {
    p = unsafe.Pointer(buf)
  } else {
    p = mallocgc(uintptr(n), nil, false)
  }
  memmove(p, unsafe.Pointer(ptr), uintptr(n))
  return unsafe.String((*byte)(p), n)
}

Đầu tiên xử lý các trường hợp đặc biệt khi slice có độ dài 0 và 1, trong trường hợp này không cần sao chép bộ nhớ. Sau đó nếu nhỏ hơn độ dài buffer thì sử dụng bộ nhớ của buffer, nếu không thì mở bộ nhớ mới, cuối cùng dùng hàm memmove để sao chép bộ nhớ trực tiếp qua, bộ nhớ sau khi sao chép không có bất kỳ liên hệ gì với bộ nhớ nguồn, nên có thể tùy ý sửa đổi.

Đáng chú ý là, hai phương pháp chuyển đổi trên đều cần sao chép bộ nhớ, nếu bộ nhớ cần sao chép rất lớn thì tiêu hao hiệu năng cũng rất lớn. Khi cập nhật lên phiên bản go1.20, gói unsafe đã cập nhật các hàm sau:

go
//传入指向内存地址的类型指针和数据长度,返回其切片表达形式
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

//传入一个切片,得到指向其底层数组的指针
func SliceData(slice []ArbitraryType) *ArbitraryType

//根据传入的地址和长度,返回字符串
func String(ptr *byte, len IntegerType) string

//传入一个字符串,返回其起始内存地址,不过返回的字节不能被修改
func StringData(str string) *byte

Đặc biệt là các hàm StringStringData, chúng không liên quan đến sao chép bộ nhớ, cũng có thể hoàn thành chuyển đổi, nhưng cần lưu ý là, tiền đề sử dụng chúng là phải đảm bảo dữ liệu là chỉ đọc, sau này không có bất kỳ sửa đổi nào, nếu không string sẽ thay đổi, xem ví dụ sau:

go
func main() {
  bs := []byte("hello,world!")
  s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
  bs[0] = 'b'
  fmt.Println(s)
}

Đầu tiên thông qua SliceData lấy địa chỉ của mảng底层 của slice byte, sau đó thông qua String lấy hình thức biểu diễn string của nó, sau đó trực tiếp sửa đổi slice byte, string cũng sẽ thay đổi, điều này rõ ràng trái với mục đích ban đầu của string. Hãy xem thêm một ví dụ:

go
func main() {
  str := "hello,world!"
  bytes := unsafe.Slice(unsafe.StringData(str), len(str))
  fmt.Println(bytes)
    // fatal
  bytes[0] = 'b'
  fmt.Println(str)
}

Sau khi lấy được hình thức biểu diễn slice của string, nếu cố sửa đổi slice byte thì sẽ trực tiếp fatal, dưới đây hãy đổi cách khai báo string xem có khác biệt gì:

go
func main() {
  var str string
  fmt.Scanln(&str)
  bytes := unsafe.Slice(unsafe.StringData(str), len(str))
  fmt.Println(bytes)
  bytes[0] = 'b'
  fmt.Println(str)
}
hello,world!
[104 101 108 108 111 44 119 111 114 108 100 33]
bello,world!

Từ kết quả có thể thấy,确实 đã sửa đổi thành công. Lý do之前 fatal là vì biến str lưu trữ là string literal, các string literal đều được lưu trữ trong segment dữ liệu chỉ đọc, không phải heap stack, về cơ bản đã断绝 khả năng string được khai báo bằng literal có thể bị sửa đổi sau này. Đối với một biến string thông thường而言, về bản chất nó确实 có thể被 sửa đổi, nhưng cách viết này compiler không cho phép. Tóm lại, sử dụng hàm unsafe để thao tác chuyển đổi string không an toàn, trừ khi có thể đảm bảo永远 không sửa đổi dữ liệu.

Duyệt

go
s := "hello world!"
for i, r := range s {
  fmt.Println(i, r)
}

Để xử lý trường hợp ký tự đa byte, việc duyệt string thường sử dụng vòng lặp for range. Khi sử dụng for range để duyệt string, compiler sẽ triển khai thành code dạng sau trong thời gian biên dịch:

go
ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    // Phán đoán có phải là ký tự đơn byte không
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    i, r = hv1t, hv2
  // Thân vòng lặp
}

Trong code được triển khai, vòng lặp for range sẽ được thay thế bằng vòng lặp for cổ điển, trong vòng lặp sẽ phán đoán byte hiện tại có phải là ký tự đơn byte không, nếu là ký tự đa byte thì sẽ gọi hàm runtime runtime.decoderune để lấy mã hóa hoàn chỉnh của nó, sau đó gán cho i, r, sau khi xử lý xong thì đến phần thân vòng lặp được định nghĩa trong code nguồn thực thi.

Công việc xây dựng code trung gian được hoàn thành bởi hàm walkRange trong cmd/compile/internal/walk/range.go, đồng thời nó cũng负责 xử lý tất cả các kiểu có thể được for range duyệt, ở đây không triển khai ra, nếu感兴趣 có thể tự tìm hiểu.

Golang by www.golangdev.cn edit