مثل كثير من لغات البرمجة الأخرى، يُعد النص أيضًا أحد أصناف الأنواع المهمة في Go. ستسرد هذه المقالة جميع الحقائق المتعلقة بالنصوص.
البنية الداخلية لأنواع النصوص
بالنسبة إلى مصرِّف Go القياسي، تُعرَّف البنية الداخلية لأي نوع نصي كما يلي:
type _string struct {
elements *byte // البايتات الأساسية
len int // عدد البايتات
}
من التعريف، نعرف أن النص في الواقع غلاف لتسلسل من byte. في الحقيقة، يمكننا حقًا النظر إلى النص على أنه شريحة بايتات (غير قابلة لتغيير عناصرها).
لاحظ أن byte في Go اسم مستعار مدمج للنوع uint8.
بعض الحقائق البسيطة عن النصوص
تعلمنا الحقائق التالية عن النصوص من المقالات السابقة.
- يمكن استخدام قيم النصوص كثوابت (إلى جانب القيم المنطقية وجميع أنواع القيم العددية).
- تدعم Go نمطين من القيم النصية الحرفية، نمط علامتي الاقتباس المزدوجتين (أو القيم الحرفية المفسَّرة) ونمط علامة الاقتباس الخلفية (أو القيم النصية الحرفية الخام).
- القيم الصفرية لأنواع النصوص هي نصوص فارغة، ويمكن تمثيلها في القيمة الحرفية بـ
""أو ````. - يمكن دمج النصوص باستخدام المعاملين
+و+=. - جميع أنواع النصوص قابلة للمقارنة (باستخدام المعاملين
==و!=). ومثل قيم الأعداد الصحيحة والقيم العشرية، يمكن أيضًا مقارنة قيمتين من النوع النصي نفسه بالمعاملات>و<و>=و<=. عند مقارنة نصين، تُقارَن بايتاتهما الأساسية بايتًا بايتًا. إذا كان أحد النصين بادئة للنص الآخر وكان النص الآخر أطول، فيُعتبر النص الآخر هو الأكبر.
مثال:
package main
import "fmt"
func main() {
const World = "world"
var hello = "hello"
// دمج النصوص.
var helloWorld = hello + " " + World
helloWorld += "!"
fmt.Println(helloWorld) // hello world!
// مقارنة النصوص.
fmt.Println(hello == "hello") // true
fmt.Println(hello > helloWorld) // false
}
حقائق أخرى عن أنواع النصوص وقيمها في Go.
- مثل Java، محتويات قيم النصوص (بايتاتها الأساسية) غير قابلة للتغيير. كما لا يمكن تعديل أطوال قيم النصوص بشكل منفصل. لا يمكن الكتابة فوق قيمة نصية قابلة للعنونة إلا ككل عبر إسناد قيمة نصية أخرى إليها.
- النوع المدمج
stringليس له طرق (مثل معظم الأنواع المدمجة الأخرى في Go)، لكن يمكننا - استخدام الدوال الموفَّرة في حزمةstringsالقياسية للقيام بجميع أنواع معالجات النصوص. - استدعاء الدالة المدمجة
lenللحصول على طول النص (عدد البايتات المخزَّنة في النص). - استخدام صيغة الوصول إلى العناصر
aString[i]المقدَّمة في الوصول إلى عناصر الحاويات للحصول على قيمةbyteرقم i المخزَّنة فيaString. التعبيرaString[i]غير قابل للعنونة. بعبارة أخرى، لا يمكن تعديل القيمةaString[i]. - استخدام صيغة الشريحة الفرعية
aString[start:end]للحصول على سلسلة فرعية منaString. هنا،startوendفهرسان لبايتات مخزَّنة فيaString. - بالنسبة إلى مصرِّف Go القياسي، سيتشارك متغير النص الهدف وقيمة النص المصدر في إسناد نصي التسلسل نفسه من البايتات الأساسية في الذاكرة. كما تشارك نتيجة تعبير السلسلة الفرعية
aString[start:end]التسلسل نفسه من البايتات الأساسية مع النص الأساسaStringفي الذاكرة.
مثال:
package main
import (
"fmt"
"strings"
)
func main() {
var helloWorld = "hello world!"
var hello = helloWorld[:5] // سلسلة فرعية
// 104 هو رمز ASCII (و Unicode) للمحرف 'h'.
fmt.Println(hello[0]) // 104
fmt.Printf("%T \n", hello[0]) // uint8
// العنصر hello[0] غير قابل للعنونة وغير قابل للتغيير،
// لذا يفشل السطران التاليان في التصريف.
/*
hello[0] = 'H' // خطأ
fmt.Println(&hello[0]) // خطأ
*/
// الجملة التالية تطبع: 5 12 true
fmt.Println(len(hello), len(helloWorld),
strings.HasPrefix(helloWorld, hello))
}
لاحظ أنه إذا كانت aString والفهارس في التعبيرين aString[i] وaString[start:end] كلها ثوابت، فإن الفهارس الثابتة الخارجة عن النطاق ستؤدي إلى فشل التصريف. ويرجى ملاحظة أن نتائج تقييم مثل هذه التعبيرات دائمًا غير ثابتة (قد يتغير هذا أو لا يتغير في إصدار لاحق من Go). مثلًا، سيطبع البرنامج التالي 4 0.
package main
import "fmt"
const s = "Go101.org" // len(s) == 9
// len(s) تعبير ثابت،
// في حين أن len(s[:]) ليس كذلك.
var a byte = 1 << len(s) / 128
var b byte = 1 << len(s[:]) / 128
func main() {
fmt.Println(a, b) // 4 0
}
لمعرفة سبب تقييم المتغيرين a وb إلى قيمتين مختلفتين، يرجى قراءة قاعدة استنتاج النوع الخاصة في عملية معامل الإزاحة البتية وأي استدعاءات الدوال تُقيَّم في وقت التصريف.
ترميز النصوص ونقاط رمز Unicode
يحدد معيار Unicode قيمة فريدة لكل محرف في جميع لغات البشر. لكن الوحدة الأساسية في Unicode ليست المحرف، بل نقطة الرمز (code point). تقابل معظم نقاط الرمز محارف، لكن قليلًا من المحارف يتكون كل منها من عدة نقاط رمز.
تُمثَّل نقاط الرمز في Go بـ قيم rune. في Go، rune اسم مستعار مدمج للنوع int32.
في التطبيقات، توجد عدة طرق ترميز لتمثيل نقاط الرمز، مثل ترميز UTF-8 وترميز UTF-16. في الوقت الحاضر، طريقة الترميز الأكثر شيوعًا هي ترميز UTF-8. في Go، تُعتبر جميع ثوابت النصوص مُرمَّزة بـ UTF-8. في وقت التصريف، ستؤدي ثوابت النصوص المُرمَّزة بـ UTF-8 بشكل غير صالح إلى فشل التصريف. أما في وقت التشغيل، فلا يمكن لوقت تشغيل Go منع بعض النصوص من أن تكون مُرمَّزة بـ UTF-8 بشكل غير صالح.
في ترميز UTF-8، قد تُخزَّن قيمة كل نقطة رمز في بايت واحد أو أكثر (حتى أربعة بايتات). مثلًا، تُخزَّن كل نقطة رمز إنجليزية (تقابل محرفًا إنجليزيًا واحدًا) في بايت واحد، بينما تُخزَّن كل نقطة رمز صينية (تقابل محرفًا صينيًا واحدًا) في ثلاثة بايتات.
تحويلات متعلقة بالنصوص
في مقالة الثوابت والمتغيرات، تعلمنا أنه يمكن تحويل الأعداد الصحيحة صراحةً إلى نصوص (لكن ليس العكس).
نقدّم هنا قاعدتي تحويل إضافيتين متعلقتين بالنصوص في Go:
- يمكن تحويل قيمة نصية صراحةً إلى شريحة بايتات، والعكس صحيح. شريحة البايتات هي شريحة نوع عناصرها الأساسي
[]byte. - يمكن تحويل قيمة نصية صراحةً إلى شريحة rune، والعكس صحيح. شريحة rune هي شريحة نوع عناصرها الأساسي
[]rune.
في التحويل من شريحة rune إلى نص، يُرمَّز كل عنصر من عناصر الشريحة (قيمة rune) بـ UTF-8 في ما بين بايت واحد وأربعة بايتات ويُخزَّن في النص الناتج. إذا كانت قيمة عنصر rune في الشريحة خارج نطاق نقاط رمز Unicode الصالحة، فستُعتبر 0xFFFD، وهي نقطة الرمز الخاصة بمحرف الاستبدال في Unicode. ستُرمَّز 0xFFFD بـ UTF-8 في ثلاثة بايتات (0xef 0xbf 0xbd).
عند تحويل نص إلى شريحة rune، تُعتبر البايتات المخزَّنة في النص تمثيلات متتالية لتسلسلات بايتات ترميز UTF-8 لكثير من نقاط رمز Unicode. ستُحوَّل تمثيلات ترميز UTF-8 السيئة إلى قيمة rune مقدارها 0xFFFD.
عند تحويل نص إلى شريحة بايتات، تكون شريحة البايتات الناتجة مجرد نسخة عميقة من تسلسل البايتات الأساسي للنص. وعند تحويل شريحة بايتات إلى نص، يكون تسلسل البايتات الأساسي للنص الناتج أيضًا مجرد نسخة عميقة من شريحة البايتات. يلزم تخصيص ذاكرة لتخزين النسخة العميقة في كل تحويل من هذه التحويلات. السبب في أن النسخة العميقة ضرورية هو أن عناصر الشرائح قابلة للتغيير بينما البايتات المخزَّنة في النصوص غير قابلة للتغيير، لذا لا يمكن لشريحة بايتات ونص أن يتشاركا عناصر البايتات.
يرجى ملاحظة أنه في التحويلات بين النصوص وشرائح البايتات،
- يُسمح بالبايتات المُرمَّزة بـ UTF-8 بشكل غير صالح وستبقى دون تغيير.
- يُجري مصرِّف Go القياسي بعض التحسينات على بعض الحالات الخاصة من هذه التحويلات، بحيث لا تُنشأ النسخ العميقة. ستُقدَّم هذه الحالات أدناه.
لا تدعم Go التحويلات بين شرائح البايتات وشرائح rune مباشرةً، ويمكننا استخدام الطرق التالية لتحقيق ذلك:
- استخدام قيم النصوص كحلقة وسيطة. هذه الطريقة مريحة لكنها ليست فعّالة كثيرًا، إذ يلزم إجراء نسختين عميقتين في العملية.
- استخدام الدوال الموجودة في حزمة unicode/utf8 القياسية.
- استخدام الدالة
Runesفي حزمة bytes القياسية لتحويل قيمة[]byteإلى قيمة[]rune. لا توجد دالة في هذه الحزمة لتحويل شريحة rune إلى شريحة بايتات.
مثال:
package main
import (
"bytes"
"unicode/utf8"
)
func Runes2Bytes(rs []rune) []byte {
n := 0
for _, r := range rs {
n += utf8.RuneLen(r)
}
n, bs := 0, make([]byte, n)
for _, r := range rs {
n += utf8.EncodeRune(bs[n:], r)
}
return bs
}
func main() {
s := "Color Infection is a fun game."
bs := []byte(s) // string -> []byte
s = string(bs) // []byte -> string
rs := []rune(s) // string -> []rune
s = string(rs) // []rune -> string
rs = bytes.Runes(bs) // []byte -> []rune
bs = Runes2Bytes(rs) // []rune -> []byte
}
تحسينات المصرِّف لتحويلات النصوص وشرائح البايتات
يرجى قراءة فصل «النصوص وشرائح البايتات» (String and Byte Slices) في كتاب Go Optimizations 101.
حلقة for-range على النصوص
ينطبق تدفق التحكم بحلقة for-range على النصوص. لكن يرجى ملاحظة أن for-range سيكرّر على نقاط رمز Unicode (كقيم rune)، بدلًا من البايتات، في النص. ستُفسَّر تمثيلات ترميز UTF-8 السيئة في النص على أنها قيمة rune مقدارها 0xFFFD.
مثال:
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
for i, rn := range s {
fmt.Printf("%2v: 0x%x %v \n", i, rn, string(rn))
}
fmt.Println(len(s))
}
ناتج البرنامج أعلاه:
0: 0x65 e
1: 0x301 ́
3: 0x915 क
6: 0x94d ्
9: 0x937 ष
12: 0x93f ि
15: 0x61 a
16: 0x3c0 π
18: 0x56e7 囧
21
من النتيجة الناتجة، يمكننا أن نجد أن
- قد لا تكون قيمة فهرس التكرار متصلة. السبب هو أن الفهرس هو فهرس البايت في النص المكرَّر عليه، وقد تحتاج نقطة رمز واحدة إلى أكثر من بايت واحد لتمثيلها.
- المحرف الأول،
é، يتكون من قيمتي rune (3 بايتات إجمالًا) - المحرف الثاني،
क्षि، يتكون من أربع قيم rune (12 بايتًا إجمالًا). - المحرف الإنجليزي،
a، يتكون من قيمة rune واحدة (بايت واحد). - المحرف
πيتكون من قيمة rune واحدة (بايتان). - المحرف الصيني،
囧، يتكون من قيمة rune واحدة (3 بايتات).
ثم كيف نكرّر على البايتات في نص؟ افعل هذا:
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
for i := 0; i < len(s); i++ {
fmt.Printf("The byte at index %v: 0x%x \n", i, s[i])
}
}
بالتأكيد، يمكننا أيضًا الاستفادة من تحسين المصرِّف المذكور أعلاه للتكرار على البايتات في نص. بالنسبة إلى مصرِّف Go القياسي، هذه الطريقة أكثر فعّالية قليلًا من الطريقة أعلاه.
package main
import "fmt"
func main() {
s := "éक्षिaπ囧"
// هنا، لا تُنسخ البايتات الأساسية للنص s.
for i, b := range []byte(s) {
fmt.Printf("The byte at index %v: 0x%x \n", i, b)
}
}
من الأمثلة العديدة أعلاه، نعرف أن len(s) ستُرجع عدد البايتات في النص s. التعقيد الزمني لـ len(s) هو O(1). كيف نحصل على عدد قيم rune في نص؟ استخدام حلقة for-range للتكرار وعدّ جميع قيم rune طريقة، واستخدام دالة RuneCountInString في حزمة unicode/utf8 القياسية طريقة أخرى. كفاءة الطريقتين متساويتان تقريبًا. الطريقة الثالثة هي استخدام len([]rune(s)) للحصول على عدد قيم rune في النص s. منذ سلسلة أدوات Go 1.11، يُجري مصرِّف Go القياسي تحسينًا على الطريقة الثالثة لتجنب نسخة عميقة غير ضرورية بحيث تصبح بفعّالية الطريقتين السابقتين. يرجى ملاحظة أن التعقيدات الزمنية لهذه الطرق كلها O(n).
مزيد من طرق دمج النصوص
يرجى قراءة فصل «النصوص وشرائح البايتات» في كتاب Go Optimizations 101.
السكر النحوي: استخدام النصوص كشرائح بايتات
من مقالة المصفوفات والشرائح والخرائط، تعلمنا أنه يمكننا استخدام الدالتين المدمجتين copy وappend لنسخ عناصر الشرائح وإلحاقها. في الواقع، وكحالة خاصة، إذا كان المعطى الأول لاستدعاء إحدى هاتين الدالتين شريحة بايتات، فيمكن أن يكون المعطى الثاني نصًا (وإذا كان الاستدعاء استدعاء append، فيجب أن يتبع معطى النص ثلاث نقاط ...). بعبارة أخرى، يمكن استخدام النص كشريحة بايتات في هذه الحالة الخاصة.
مثال:
package main
import "fmt"
func main() {
hello := []byte("Hello ")
world := "world!"
// الطريقة العادية:
// helloWorld := append(hello, []byte(world)...)
helloWorld := append(hello, world...) // طريقة السكر النحوي
fmt.Println(string(helloWorld))
helloWorld2 := make([]byte, len(hello) + len(world))
copy(helloWorld2, hello)
// الطريقة العادية:
// copy(helloWorld2[len(hello):], []byte(world))
copy(helloWorld2[len(hello):], world) // طريقة السكر النحوي
fmt.Println(string(helloWorld2))
}
المزيد عن مقارنات النصوص
ذُكر أعلاه أن مقارنة نصين هي في الواقع مقارنة بايتاتهما الأساسية. عمومًا، ستُجري مصرِّفات Go التحسينات التالية على مقارنات النصوص.
- في مقارنات
==و!=، إذا لم يتساوَ طولا النصين المقارنين، فلا بد أن يكون النصان غير متساويين أيضًا (دون حاجة إلى مقارنة بايتاتهما). - إذا تساوى مؤشرا تسلسل البايتات الأساسي للنصين المقارنين، فإن نتيجة المقارنة تكون نفس نتيجة مقارنة طولي النصين.
لذا، بالنسبة إلى نصين متساويين، يعتمد التعقيد الزمني لمقارنتهما على ما إذا كان مؤشرا تسلسل البايتات الأساسي لهما متساويين أم لا. إذا تساوى المؤشران، فالتعقيد الزمني هو O(1)، وإلا فالتعقيد الزمني هو O(n)، حيث n هو طول النصين.
كما ذُكر أعلاه، بالنسبة إلى مصرِّف Go القياسي، في إسناد قيمة نصية، تتشارك قيمة النص الهدف وقيمة النص المصدر التسلسل نفسه من البايتات الأساسية في الذاكرة. لذا تصبح تكلفة مقارنة النصين صغيرة جدًا.
مثال:
package main
import (
"fmt"
"time"
)
func main() {
bs := make([]byte, 1<<26)
s0 := string(bs)
s1 := string(bs)
s2 := s1
// s0 وs1 وs2 ثلاثة نصوص متساوية.
// البايتات الأساسية لـ s0 نسخة من bs.
// البايتات الأساسية لـ s1 أيضًا نسخة من bs.
// البايتات الأساسية لـ s0 وs1 نسختان
// مختلفتان من bs.
// يتشارك s2 البايتات الأساسية نفسها مع s1.
startTime := time.Now()
_ = s0 == s1
duration := time.Now().Sub(startTime)
fmt.Println("duration for (s0 == s1):", duration)
startTime = time.Now()
_ = s1 == s2
duration = time.Now().Sub(startTime)
fmt.Println("duration for (s1 == s2):", duration)
}
الناتج:
duration for (s0 == s1): 10.462075ms
duration for (s1 == s2): 136ns
1ms تساوي 1000000ns! لذا يرجى محاولة تجنب مقارنة نصين طويلين إذا لم يتشاركا التسلسل نفسه من البايتات الأساسية.