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 نفسه يشغل ذاكرة صغيرة جدًا أي مؤشر للبيانات الحقيقية، بهذا تكون تكلفة تمرير السلسلة النصية منخفضة جدًا. أرى شخصيًا أنه بما أنه يحمل فقط مرجعًا للذاكرة، إذا كان يمكن تعديله بحرية، سيصعب معرفة لاحقًا ما إذا كانت الإشارة الأصلية لا تزال تشير للبيانات المطلوبة (إما باستخدام الانعكاس أو حزمة 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
    }
    // تجاوز رقمي
    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 يخزن قيمة حرفية للسلسلة النصية، القيم الحرفية للسلاسل تُخزن في قسم البيانات للقراءة فقط، وليس في الكومة أو المكدس، وهذا يمنع从根本上 إمكانية تعديل السلاسل المُعلنة كقيم حرفية. بالنسبة لمتغير سلسلة عادي، في الجوهر يمكن تعديله، لكن هذه الكتابة لا يسمح بها المترجم. باختصار، استخدام دوال 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 تم تحريره بواسطة www.golangdev.cn