Skip to content

string

string เป็นหนึ่งในประเภทข้อมูลพื้นฐานที่พบได้บ่อยมากใน Go และเป็นประเภทข้อมูลแรกที่ฉันได้สัมผัสในภาษา Go

go
package main

import "fmt"

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

เชื่อว่าโค้ดนี้คนส่วนใหญ่เคยพิมพ์เมื่อเริ่มเรียนรู้ Go ใน builtin/builtin.go มีคำอธิบายสั้นๆ เกี่ยวกับ 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

จากข้อความข้างต้นได้ข้อมูลดังนี้

  • string คือเซตของไบต์ 8 บิต

  • ประเภท string มักเป็นการเข้ารหัส UTF-8

  • string สามารถเป็นค่าว่างได้ แต่ไม่ใช่ nil

  • string ไม่สามารถเปลี่ยนแปลงได้

คุณสมบัติเหล่านี้คนที่ใช้ Go บ่อยๆ ควรรู้ดีอยู่แล้ว ต่อไปมาดูสิ่งที่แตกต่างบ้าง

โครงสร้าง

ใน Go สตริงแสดงด้วยโครงสร้าง runtime.stringStruct ในขณะทำงาน แต่ไม่ได้เปิดเผยออกมา แทนที่ด้วย reflect.StringHeader

TIP

แม้ว่า StringHeader จะถูกเลิกใช้แล้วในเวอร์ชัน go.1.21 แต่มันก็เข้าใจง่าย เนื้อหาด้านล่างยังคงใช้มันเพื่ออธิบาย ไม่กระทบต่อความเข้าใจ รายละเอียดดูที่ 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
}

ฟิลด์มีความหมายดังนี้

  • Data เป็นตัวชี้ไปยังที่อยู่เริ่มต้นของหน่วยความจำสตริง
  • Len จำนวนไบต์ของสตริง

ด้านล่างเป็นตัวอย่างการเข้าถึงที่อยู่สตริงผ่านตัวชี้ 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]))))))
  }
}

แต่ตอนนี้ Go แนะนำให้ใช้ unsafe.StringData แทน

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]))))))
  }
}

ทั้งสองให้ผลลัพธ์เหมือนกัน

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

โดยพื้นฐานแล้วสตริงคือที่อยู่หน่วยความจำที่ต่อเนื่องกัน แต่ละที่อยู่เก็บหนึ่งไบต์ กล่าวอีกนัยหนึ่งคืออาร์เรย์ของไบต์ ผลลัพธ์ที่ได้จากฟังก์ชัน len คือจำนวนไบต์ ไม่ใช่จำนวนอักขระในสตริง โดยเฉพาะเมื่ออักขระในสตริงเป็นอักขระที่ไม่ใช่ ASCII

string เองใช้หน่วยความจำน้อยมาก คือตัวชี้ไปยังข้อมูลจริง ทำให้ต้นทุนการส่งผ่านสตริงต่ำมาก ตามความเห็นส่วนตัว เนื่องจากถือการอ้างอิงหน่วยความจำเพียงอย่างเดียว หากสามารถแก้ไขได้อย่างอิสระ จะยากที่จะรู้ว่าข้อมูลที่ชี้ไปยังเดิมยังคงเป็นข้อมูลที่ต้องการหรือไม่ (ต้องใช้ reflection หรือแพ็กเกจ unsafe) เว้นแต่ผู้ใช้ข้อมูลเก่าจะไม่ต้องการสตริงนี้อีกหลังจากใช้งาน ข้อดีอีกอย่างคือปลอดภัยต่อการทำงานพร้อมกันโดยธรรมชาติ ไม่มีใครสามารถแก้ไขได้ในสถานการณ์ปกติ

การต่อกัน

ไวยากรณ์การต่อสตริงแสดงด้านล่าง ใช้ตัวดำเนินการ + เพื่อต่อกันโดยตรง

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

การต่อกันดำเนินการโดยฟังก์ชัน runtime.concatstrings ในขณะทำงาน หากเป็นสตริงตัวอักษรแบบนี้ คอมไพเลอร์จะอนุมานผลลัพธ์ได้โดยตรง

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

เมื่อส่งออกโค้ดแอสเซมบลีจะทราบผลลัพธ์ บางส่วนแสดงดังนี้

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

เห็นได้ชัดว่าคอมไพเลอร์ถือว่าเป็นสตริงเดียวเต็มรูปแบบ ค่าของมันถูกกำหนดในช่วงคอมไพล์แล้ว ไม่ได้ถูกต่อโดย runtime.concatstrings ในขณะทำงาน มีเพียงการต่อตัวแปรสตริงเท่านั้นที่จะเสร็จในขณะทำงาน ลายเซ็นฟังก์ชันดังนี้ รับอาร์เรย์ไบต์และสไลซ์สตริง

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

เมื่อตัวแปรสตริงที่ต่อกันน้อยกว่า 5 จะใช้ฟังก์ชันด้านล่างแทน (คาดเดาส่วนตัว: จากการส่งพารามิเตอร์และตัวแปรไม่ระบุชื่อ ทั้งหมดอยู่บนสแต็ก ดีกว่าสไลซ์ที่สร้างในขณะทำงานสำหรับ GC?) แม้สุดท้ายจะเสร็จโดย concatstrings

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})
}

มาดูว่าฟังก์ชัน concatstrings ทำอะไรข้างใน

go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    // ข้ามหากความยาวเป็น 0
    if n == 0 {
      continue
    }
    // คำนวณตัวเลข overflow
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    // นับจำนวน
    count++
    idx = i
  }
  // ไม่มีสตริงก็คืนค่าว่าง
  if count == 0 {
    return ""
  }

  // หากมีเพียงสตริงเดียวก็คืนค่าโดยตรง
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  // เปิดหน่วยความจำสำหรับสตริงใหม่
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
        // คัดลอก
    copy(b, x)
        // ตัด
    b = b[len(x):]
  }
  return s
}

อย่างแรกที่ทำคือนับความยาวรวมและจำนวนของสตริงที่จะต่อ จากนั้นจัดสรรหน่วยความจำตามความยาวรวม ฟังก์ชัน rawstringtmp จะคืนค่าสตริง s และสไลซ์ไบต์ b แม้ความยาวจะกำหนดไว้แต่ไม่มีเนื้อหาใดๆ เพราะโดยพื้นฐานแล้วเป็นตัวชี้ไปยังที่อยู่หน่วยความจำใหม่ทั้งสอง โค้ดจัดสรรหน่วยความจำดังนี้

go
func rawstring(size int) (s string, b []byte) {
    // ไม่ได้ระบุประเภท
  p := mallocgc(uintptr(size), nil, false)
    // แม้จัดสรรหน่วยความจำแล้วแต่ข้างบนไม่มีอะไร
  return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

สตริง s ที่คืนมาเพื่อความสะดวกในการแสดง สไลซ์ไบต์ b เพื่อความสะดวกในการแก้ไขสตริง ทั้งสองชี้ไปยังที่อยู่หน่วยความจำเดียวกัน

go
for _, x := range a {
    // คัดลอก
    copy(b, x)
    // ตัด
    b = b[len(x):]
}

ฟังก์ชัน copy เรียกใช้ runtime.slicecopy ในขณะทำงาน ซึ่งทำงานคัดลอกหน่วยความจำจาก src ไปยังที่อยู่ dst โดยตรง เมื่อคัดลอกสตริงทั้งหมดเสร็จสิ้น กระบวนการต่อกันก็เสร็จสิ้น หากสตริงที่คัดลอกมีขนาดใหญ่มาก กระบวนการนี้จะใช้ประสิทธิภาพมาก

การแปลง

ที่กล่าวมาข้างต้น สตริงไม่สามารถแก้ไขได้ หากลองแก้ไขจะไม่สามารถคอมไพล์ผ่าน Go จะแจ้งข้อผิดพลาดดังนี้

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

หากต้องการแก้ไขสตริง ต้องแปลงเป็นสไลซ์ไบต์ []byte ก่อน ใช้งานง่ายมาก

go
bs := []byte(str)

ภายในเรียกฟังก์ชัน runtime.stringtoslicebyte ซึ่งมีตรรกะง่ายมาก โค้ดดังนี้

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
}

หากความยาวสตริงน้อยกว่าความยาวบัฟเฟอร์ก็คืนค่าสไลซ์ไบต์ของบัฟเฟอร์โดยตรง เช่นนี้สามารถประหยัดหน่วยความจำเมื่อแปลงสตริงขนาดเล็ก มิฉะนั้นจะเปิดหน่วยความจำใหม่เท่ากับความยาวสตริง แล้วคัดลอกสตริงไปยังที่อยู่หน่วยความจำใหม่ ฟังก์ชัน rawbyteslice(len(s)) ทำหน้าที่คล้ายกับฟังก์ชัน rawstring ก่อนหน้า คือจัดสรรหน่วยความจำ

ในทำนองเดียวกัน สไลซ์ไบต์สามารถแปลงเป็นสตริงได้ง่ายในไวยากรณ์

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

ภายในเรียกฟังก์ชัน runtime.slicebytetostring ซึ่งเข้าใจง่าย โค้ดดังนี้

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)
}

อย่างแรกจัดการกรณีพิเศษที่ความยาวสไลซ์เป็น 0 และ 1 ในกรณีนี้ไม่ต้องคัดลอกหน่วยความจำ จากนั้นหากความยาวน้อยกว่าความยาวบัฟเฟอร์ก็ใช้หน่วยความจำบัฟเฟอร์ มิฉะนั้นเปิดหน่วยความจำใหม่ สุดท้ายใช้ฟังก์ชัน memmove คัดลอกหน่วยความจำโดยตรง หน่วยความจำที่คัดลอกแล้วไม่มีความสัมพันธ์กับหน่วยความจำต้นทาง จึงสามารถแก้ไขได้อย่างอิสระ

ควรสังเกตว่าการแปลงทั้งสองวิธีข้างต้นต้องการคัดลอกหน่วยความจำ หากหน่วยความจำที่จะคัดลอกมีขนาดใหญ่มาก จะใช้ประสิทธิภาพมาก เมื่ออัปเดตเป็น go1.20 แพ็กเกจ unsafe อัปเดตฟังก์ชันต่อไปนี้

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

โดยเฉพาะฟังก์ชัน String และ StringData ไม่เกี่ยวข้องกับการคัดลอกหน่วยความจำ ก็สามารถแปลงได้ แต่ควรสังเกตว่า การใช้พวกมันต้องมีเงื่อนไขว่าข้อมูลเป็นแบบอ่านอย่างเดียว ไม่มีการแก้ไขในภายหลัง มิฉะนั้นสตริงจะเปลี่ยนแปลง ดูตัวอย่างด้านล่าง

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

อย่างแรกใช้ SliceData เพื่อรับที่อยู่ของอาร์เรย์ระดับล่างของสไลซ์ไบต์ แล้วใช้ String เพื่อรับรูปแบบสตริง จากนั้นแก้ไขสไลซ์ไบต์โดยตรง สตริงก็เปลี่ยนแปลงด้วย ซึ่งขัดแย้งกับจุดประสงค์เดิมของสตริง ดูตัวอย่างอีกอัน

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

หลังจากได้รับรูปแบบสไลซ์ของสตริงแล้ว หากลองแก้ไขสไลซ์ไบต์ จะ fatal โดยตรง ลองเปลี่ยนวิธีประกาศสตริงดูว่ามีความแตกต่างอย่างไร

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!

จากผลลัพธ์จะเห็นว่าแก้ไขสำเร็จจริงๆ ที่ fatal ก่อนหน้านี้เพราะตัวแปร str เก็บสตริงตัวอักษร สตริงตัวอักษรเก็บในส่วนข้อมูลอ่านอย่างเดียว ไม่ใช่ heap หรือ stack从根本上ตัดความเป็นไปได้ที่สตริงที่ประกาศด้วยตัวอักษรจะถูกแก้ไขในภายหลัง สำหรับตัวแปรสตริงธรรมดา โดยพื้นฐานแล้วสามารถแก้ไขได้จริง แต่คอมไพเลอร์ไม่อนุญาต写法总之استفادهتابعهههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههههه......总之,使用 unsafe 函数来操作字符串转换并不安全,除非能保证永远不会对数据进行修改。

การ遍历

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

เพื่อจัดการกับกรณีอักขระหลายไบต์ การ遍历สตริงมักใช้ลูป for range เมื่อใช้ for range遍历สตริง คอมไพเลอร์จะขยายเป็นโค้ดรูปแบบดังนี้ในช่วงคอมไพล์

go
ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    // ตรวจสอบว่าเป็นอักขระไบต์เดียวหรือไม่
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    i, r = hv1t, hv2
  // 循环体
}

ในโค้ดที่ขยาย ลูป for range จะแทนที่ด้วยลูป for แบบคลาสสิก ในลูปจะตรวจสอบว่าไบต์ปัจจุบันเป็นอักขระไบต์เดียวหรือไม่ หากเป็นอักขระหลายไบต์จะเรียกฟังก์ชัน runtime.decoderune ในขณะทำงานเพื่อรับรหัสที่สมบูรณ์ จากนั้นกำหนดค่าให้ i, r หลังจากจัดการเสร็จแล้วก็ดำเนินการตามลูปที่กำหนดไว้ในซอร์สโค้ด

งานที่รับผิดชอบในการสร้างโค้ดกลางเสร็จโดยฟังก์ชัน walkRange ใน cmd/compile/internal/walk/range.go ซึ่งรับผิดชอบจัดการทุกประเภทที่สามารถ遍历ด้วย for range ได้ ที่นี่ไม่ขยายแล้ว หากสนใจสามารถ去了解ได้เอง

Golang by www.golangdev.cn edit