فصل سوم - انواع ترکیبی (Composite Types)

در فصل قبل، شما با لیترال‌ها و انواع متغیرهای از پیش تعریف شده آشنا شدید: اعداد، بولین‌ها و رشته‌ها. در این فصل، درباره انواع ترکیبی در Go، توابع داخلی که از آن‌ها پشتیبانی می‌کنند و بهترین شیوه‌های کار با آن‌ها یاد خواهید گرفت.

blogs tailwind section

در فصل قبل، شما با لیترال‌ها و انواع متغیرهای از پیش تعریف شده آشنا شدید: اعداد، بولین‌ها و رشته‌ها. در این فصل، درباره انواع ترکیبی در Go، توابع داخلی که از آن‌ها پشتیبانی می‌کنند و بهترین شیوه‌های کار با آن‌ها یاد خواهید گرفت.

آرایه‌ها—بیش از حد سخت‌گیرانه برای استفاده مستقیم

مانند اکثر زبان‌های برنامه‌نویسی، Go دارای آرایه است. با این حال، آرایه‌ها به ندرت مستقیماً در Go استفاده می‌شوند. به زودی خواهید فهمید چرا، اما ابتدا بیایید به سرعت نحو اعلان آرایه و استفاده از آن را بررسی کنیم.

تمام عناصر در آرایه باید از نوعی باشند که مشخص شده است. چند سبک اعلان وجود دارد. در اولی، شما اندازه آرایه و نوع عناصر در آرایه را مشخص می‌کنید:

var x [3]int

این کار یک آرایه از سه int ایجاد می‌کند. از آنجایی که هیچ مقداری مشخص نشده، تمام عناصر (x0، x1 و x2) به مقدار صفر برای یک int مقداردهی اولیه می‌شوند، که (البته) ۰ است. اگر مقادیر اولیه برای آرایه دارید، آن‌ها را با یک لیترال آرایه مشخص می‌کنید:

var x = [3]int{10, 20, 30}

اگر آرایه پراکنده‌ای دارید (آرایه‌ای که اکثر عناصر آن به مقدار صفر تنظیم شده‌اند)، می‌توانید فقط شاخص‌هایی با مقادیر غیرصفر را در لیترال آرایه مشخص کنید:

var x = [12]int{1, 5: 4, 6, 10: 100, 15}

این کار یک آرایه از ۱۲ int با مقادیر زیر ایجاد می‌کند: 1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15.

هنگام استفاده از لیترال آرایه برای مقداردهی اولیه یک آرایه، می‌توانید عددی که تعداد عناصر در آرایه را مشخص می‌کند را با ... جایگزین کنید:

var x = [...]int{10, 20, 30}

می‌توانید از == و != برای مقایسه دو آرایه استفاده کنید. آرایه‌ها برابر هستند اگر طول یکسانی داشته باشند و حاوی مقادیر برابر باشند:

var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
fmt.Println(x == y) // prints true

Go فقط آرایه‌های یک بعدی دارد، اما می‌توانید آرایه‌های چند بعدی را شبیه‌سازی کنید:

var x [2][3]int

این کار x را به عنوان آرایه‌ای با طول ۲ اعلان می‌کند که نوع آن آرایه‌ای از int‌ها با طول ۳ است. این موضوع تدقیق‌آمیز به نظر می‌رسد، اما برخی زبان‌ها پشتیبانی واقعی از ماتریس دارند، مانند Fortran یا Julia؛ Go یکی از آن‌ها نیست.

مانند اکثر زبان‌ها، آرایه‌ها در Go با استفاده از نحو براکت خوانده و نوشته می‌شوند:

x[0] = 10
fmt.Println(x[2])

نمی‌توانید فراتر از انتهای آرایه بخوانید یا بنویسید یا از شاخص منفی استفاده کنید. اگر این کار را با شاخص ثابت یا لیترال انجام دهید، خطای زمان کامپایل است. خواندن یا نوشتن خارج از محدوده با شاخص متغیر کامپایل می‌شود اما در زمان اجرا با panic شکست می‌خورد (درباره panic‌ها در "panic and recover" در صفحه ۲۱۸ بیشتر یاد خواهید گرفت).

در نهایت، تابع داخلی len یک آرایه می‌گیرد و طول آن را برمی‌گرداند:

fmt.Println(len(x))

قبلاً گفتم که آرایه‌ها در Go به ندرت صراحتاً استفاده می‌شوند. این به این دلیل است که آن‌ها با محدودیت غیرمعمولی همراه هستند: Go اندازه آرایه را بخشی از نوع آرایه در نظر می‌گیرد. این کار آرایه‌ای که به عنوان 3int اعلان شده نوعی متفاوت از آرایه‌ای می‌کند که به عنوان 4int اعلان شده است. این همچنین به این معنی است که نمی‌توانید از متغیر برای مشخص کردن اندازه آرایه استفاده کنید، زیرا انواع باید در زمان کامپایل حل شوند، نه در زمان اجرا.

علاوه بر این، نمی‌توانید از تبدیل نوع برای تبدیل مستقیم آرایه‌های اندازه‌های مختلف به انواع یکسان استفاده کنید. از آنجایی که نمی‌توانید آرایه‌های اندازه‌های مختلف را به یکدیگر تبدیل کنید، نمی‌توانید تابعی بنویسید که با آرایه‌های هر اندازه‌ای کار کند و نمی‌توانید آرایه‌های اندازه‌های مختلف را به همان متغیر اختصاص دهید.

خواهید آموخت که آرایه‌ها چگونه در پشت صحنه کار می‌کنند وقتی که درباره طرح‌بندی حافظه در فصل ۶ بحث کنم.

به دلیل این محدودیت‌ها، از آرایه‌ها استفاده نکنید مگر اینکه طول دقیق مورد نیاز خود را از قبل بدانید. به عنوان مثال، برخی از توابع رمزنگاری در کتابخانه استاندارد آرایه برمی‌گردانند زیرا اندازه‌های checksum به عنوان بخشی از الگوریتم تعریف شده‌اند. این استثنا است، نه قانون.

این سؤال را مطرح می‌کند: چرا چنین ویژگی محدودی در زبان وجود دارد؟ دلیل اصلی وجود آرایه‌ها در Go این است که فضای پشتیبان برای slice‌ها را فراهم کنند، که یکی از مفیدترین ویژگی‌های Go هستند.

برش‌ها (Slices)

بیشتر اوقات، وقتی که می‌خواهید ساختار داده‌ای داشته باشید که دنباله‌ای از مقادیر را نگه دارد، برش همان چیزی است که باید استفاده کنید. آنچه که برش‌ها را بسیار مفید می‌کند این است که می‌توانید آنها را در صورت نیاز رشد دهید. این به این دلیل است که طول یک برش بخشی از نوع آن نیست. این امر بزرگترین محدودیت‌های آرایه‌ها را برطرف می‌کند و به شما اجازه می‌دهد تابع واحدی بنویسید که برش‌هایی با هر اندازه‌ای را پردازش کند (نوشتن توابع را در فصل ۵ پوشش خواهم داد). پس از بررسی مبانی استفاده از برش‌ها در Go، بهترین روش‌های استفاده از آنها را پوشش خواهم داد.

کار با برش‌ها بسیار شبیه کار با آرایه‌ها به نظر می‌رسد، اما تفاوت‌های ظریفی وجود دارد. اولین چیزی که باید متوجه شوید این است که هنگام اعلان یک برش، اندازه آن را مشخص نمی‌کنید:

var x = []int{10, 20, 30}

استفاده از [...] یک آرایه می‌سازد. استفاده از [] یک برش می‌سازد.

این کد یک برش از سه عدد صحیح با استفاده از لیترال برش ایجاد می‌کند. درست مانند آرایه‌ها، می‌توانید فقط شاخص‌هایی که مقادیر غیر صفر دارند را در لیترال برش مشخص کنید:

var x = []int{1, 5: 4, 6, 10: 100, 15}

این کد یک برش از ۱۲ عدد صحیح با مقادیر زیر ایجاد می‌کند: [1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15].

می‌توانید برش‌های چندبُعدی را شبیه‌سازی کنید و برشی از برش‌ها بسازید:

var x [][]int

برش‌ها را با استفاده از نحو کروشه می‌خوانید و می‌نویسید، و درست مانند آرایه‌ها، نمی‌توانید از انتها یا با استفاده از شاخص منفی بخوانید یا بنویسید:

x[0] = 10
fmt.Println(x[2])

تا اینجا، برش‌ها مشابه آرایه‌ها به نظر می‌رسند. تفاوت‌های بین آرایه‌ها و برش‌ها را زمانی مشاهده می‌کنید که به اعلان برش‌ها بدون استفاده از لیترال نگاه کنید:

var x []int

این کد یک برش از اعداد صحیح ایجاد می‌کند. از آنجایی که هیچ مقداری تخصیص داده نشده، x به مقدار صفر برای یک برش تخصیص داده شده که چیزی است که قبلاً ندیده‌اید: nil. در فصل ۶ بیشتر در مورد nil صحبت خواهم کرد، اما کمی متفاوت از null است که در زبان‌های دیگر یافت می‌شود. در Go، nil شناسه‌ای است که نمایانگر عدم وجود مقدار برای برخی انواع است. مانند ثابت‌های عددی بدون نوع که در فصل قبل دیدید، nil نوعی ندارد، بنابراین می‌تواند به مقادیر انواع مختلف تخصیص داده شود یا با آنها مقایسه شود. یک برش nil چیزی در خود ندارد.

برش اولین نوعی است که دیده‌اید که قابل مقایسه نیست. این یک خطای زمان کامپایل است که از == برای دیدن اینکه آیا دو برش یکسان هستند یا از != برای دیدن اینکه آیا متفاوت هستند استفاده کنید. تنها چیزی که می‌توانید یک برش را با آن با استفاده از == مقایسه کنید nil است:

fmt.Println(x == nil) // prints true

از Go 1.21، بسته slices در کتابخانه استاندارد شامل دو تابع برای مقایسه برش‌ها است. تابع slices.Equal دو برش می‌گیرد و اگر برش‌ها همان طول را داشته باشند و همه عناصر برابر باشند true برمی‌گرداند. این تابع نیاز دارد که عناصر برش قابل مقایسه باشند. تابع دیگر، slices.EqualFunc، به شما اجازه می‌دهد تابعی را برای تعیین برابری ارسال کنید و نیاز ندارد که عناصر برش قابل مقایسه باشند. در بخش "ارسال توابع به عنوان پارامتر" در صفحه ۱۰۷ در مورد ارسال توابع به توابع یاد خواهید گرفت. سایر توابع در بسته slices در بخش "اضافه کردن Generics به کتابخانه استاندارد" در صفحه ۲۰۱ پوشش داده شده‌اند.

x := []int{1, 2, 3, 4, 5}
y := []int{1, 2, 3, 4, 5}
z := []int{1, 2, 3, 4, 5, 6}
s := []string{"a", "b", "c"}
fmt.Println(slices.Equal(x, y)) // prints true
fmt.Println(slices.Equal(x, z)) // prints false
fmt.Println(slices.Equal(x, s)) // does not compile

بسته reflect حاوی تابعی به نام DeepEqual است که می‌تواند تقریباً هر چیزی، از جمله برش‌ها را مقایسه کند. این یک تابع قدیمی است که عمدتاً برای تست در نظر گرفته شده است. قبل از گنجاندن slices.Equal و slices.EqualFunc، reflect.DeepEqual اغلب برای مقایسه برش‌ها استفاده می‌شد. در کد جدید از آن استفاده نکنید، زیرا کندتر و کم‌ایمن‌تر از استفاده از توابع موجود در بسته slices است.

len

Go چندین تابع داخلی برای کار با برش‌ها فراهم می‌کند. تابع داخلی len را هنگام نگاه کردن به آرایه‌ها قبلاً دیده‌اید. برای برش‌ها نیز کار می‌کند. ارسال یک برش nil به len عدد ۰ برمی‌گرداند.

توابعی مانند len در Go داخلی هستند زیرا می‌توانند کارهایی انجام دهند که توسط توابعی که شما می‌توانید بنویسید قابل انجام نیست. قبلاً دیده‌اید که پارامتر len می‌تواند هر نوع آرایه یا هر نوع برش باشد. به زودی خواهید دید که برای رشته‌ها و نقشه‌ها نیز کار می‌کند. در بخش "کانال‌ها" در صفحه ۲۹۱، خواهید دید که با کانال‌ها نیز کار می‌کند. تلاش برای ارسال متغیری از هر نوع دیگر به len یک خطای زمان کامپایل است. همانطور که در فصل ۵ خواهید دید، Go به توسعه‌دهندگان اجازه نمی‌دهد تابعی بنویسند که هر رشته، آرایه، برش، کانال یا نقشه را بپذیرد، اما انواع دیگر را رد کند.

append

تابع داخلی append برای رشد دادن برش‌ها استفاده می‌شود:

var x []int
x = append(x, 10) // assign result to the variable that's passed in

تابع append حداقل دو پارامتر می‌گیرد، یک برش از هر نوع و یک مقدار از همان نوع. برشی از همان نوع برمی‌گرداند که به متغیری که به append ارسال شده تخصیص داده می‌شود. در این مثال، شما به یک برش nil اضافه می‌کنید، اما می‌توانید به برشی که قبلاً عناصر دارد اضافه کنید:

var x = []int{1, 2, 3}
x = append(x, 4)

می‌توانید بیش از یک مقدار را در یک زمان اضافه کنید:

x = append(x, 5, 6, 7)

یک برش با استفاده از عملگر ... برای گسترش برش منبع به مقادیر فردی به برش دیگری اضافه می‌شود (در بخش "پارامترهای ورودی متغیر و برش‌ها" در صفحه ۹۵ بیشتر در مورد عملگر ... یاد خواهید گرفت):

y := []int{20, 30, 40}
x = append(x, y...)

اگر فراموش کنید مقدار برگردانده شده از append را تخصیص دهید، این یک خطای زمان کامپایل است. ممکن است تعجب کنید که چرا، زیرا کمی تکراری به نظر می‌رسد. در فصل ۵ با جزئیات بیشتری در مورد این موضوع صحبت خواهم کرد، اما Go یک زبان فراخوانی بر اساس مقدار است. هر بار که پارامتری را به یک تابع ارسال می‌کنید، Go کپی از مقداری که ارسال شده می‌سازد. ارسال یک برش به تابع append در واقع کپی از برش را به تابع ارسال می‌کند. تابع مقادیر را به کپی برش اضافه می‌کند و کپی را برمی‌گرداند. سپس برش برگردانده شده را به متغیر در تابع فراخوان‌کننده تخصیص می‌دهید.

ظرفیت (Capacity)

همانطور که دیده‌اید، برش دنباله‌ای از مقادیر است. هر عنصر در یک برش به مکان‌های حافظه متوالی تخصیص داده می‌شود، که خواندن یا نوشتن این مقادیر را سریع می‌کند. طول یک برش تعداد مکان‌های حافظه متوالی است که مقداری به آنها تخصیص داده شده. هر برش همچنین ظرفیتی دارد که تعداد مکان‌های حافظه متوالی رزرو شده است. این می‌تواند بزرگتر از طول باشد. هر بار که به یک برش اضافه می‌کنید، یک یا چند مقدار به انتهای برش اضافه می‌شود. هر مقدار اضافه شده طول را یکی افزایش می‌دهد. وقتی طول به ظرفیت می‌رسد، دیگر جایی برای قرار دادن مقادیر نیست. اگر سعی کنید مقادیر اضافی اضافه کنید وقتی طول برابر ظرفیت است، تابع append از زمان اجرای Go برای تخصیص آرایه پشتیبان جدید برای برش با ظرفیت بزرگتر استفاده می‌کند. مقادیر در آرایه پشتیبان اصلی به آرایه جدید کپی می‌شوند، مقادیر جدید به انتهای آرایه پشتیبان جدید اضافه می‌شوند، و برش به‌روزرسانی می‌شود تا به آرایه پشتیبان جدید اشاره کند. در نهایت، برش به‌روزرسانی شده برگردانده می‌شود.

زمان اجرای Go (Go Runtime)

هر زبان سطح بالا بر مجموعه‌ای از کتابخانه‌ها تکیه می‌کند تا برنامه‌هایی که با آن زبان نوشته شده‌اند بتوانند اجرا شوند، و Go نیز استثنا نیست. زمان اجرای Go خدماتی مانند تخصیص حافظه و جمع‌آوری زباله، پشتیبانی همزمانی، شبکه، و پیاده‌سازی انواع و توابع داخلی فراهم می‌کند.

زمان اجرای Go در هر باینری Go کامپایل می‌شود. این متفاوت از زبان‌هایی است که از ماشین مجازی استفاده می‌کنند، که باید جداگانه نصب شوند تا برنامه‌هایی که با آن زبان‌ها نوشته شده‌اند بتوانند کار کنند. گنجاندن زمان اجرا در باینری توزیع برنامه‌های Go را آسان‌تر می‌کند و نگرانی‌های مربوط به مسائل سازگاری بین زمان اجرا و برنامه را برطرف می‌کند. عیب گنجاندن زمان اجرا در باینری این است که حتی ساده‌ترین برنامه Go باینری حدود ۲ مگابایتی تولید می‌کند.

وقتی یک برش از طریق append رشد می‌کند، زمان اجرای Go برای تخصیص حافظه جدید و کپی کردن داده‌های موجود از حافظه قدیم به حافظه جدید زمان می‌برد. حافظه قدیم نیز باید جمع‌آوری زباله شود. به همین دلیل، زمان اجرای Go معمولاً برش را بیش از یکی هر بار که ظرفیت تمام می‌شود افزایش می‌دهد. قانون از Go 1.18 این است که ظرفیت یک برش را دو برابر کند وقتی ظرفیت فعلی کمتر از ۲۵۶ است. برش بزرگتر با (current_capacity + 768)/4 افزایش می‌یابد. این به آرامی به رشد ۲۵٪ همگرا می‌شود (برشی با ظرفیت ۵۱۲ ۶۳٪ رشد خواهد کرد، اما برشی با ظرفیت ۴,۰۹۶ تنها ۳۰٪ رشد خواهد کرد).

درست مانند تابع داخلی len که طول فعلی یک برش را برمی‌گرداند، تابع داخلی cap ظرفیت فعلی یک برش را برمی‌گرداند. خیلی کمتر از len استفاده می‌شود. بیشتر اوقات، cap برای بررسی اینکه آیا یک برش برای نگه داشتن داده‌های جدید به اندازه کافی بزرگ است یا اینکه آیا فراخوانی make برای ایجاد برش جدید لازم است استفاده می‌شود.

همچنین می‌توانید یک آرایه را به تابع cap ارسال کنید، اما cap همیشه همان مقداری که len برای آرایه‌ها برمی‌گرداند را برمی‌گرداند. آن را در کد خود قرار ندهید، اما این ترفند را برای شب پرسش و پاسخ Go نگه دارید.

بیایید نگاهی بیندازیم که چگونه اضافه کردن عناصر به یک برش طول و ظرفیت را تغییر می‌دهد. کد مثال ۳-۱ را در Go Playground یا در دایرکتوری sample_code/len_cap در مخزن فصل ۳ اجرا کنید.

مثال ۳-۱. درک ظرفیت

var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 20)
fmt.Println(x, len(x), cap(x))
x = append(x, 30)
fmt.Println(x, len(x), cap(x))
x = append(x, 40)
fmt.Println(x, len(x), cap(x))
x = append(x, 50)
fmt.Println(x, len(x), cap(x))

وقتی کد را بیلد و اجرا می‌کنید، خروجی زیر را خواهید دید. توجه کنید که چگونه و چه زمانی ظرفیت افزایش می‌یابد:

[] 0 0
[10] 1 1
[10 20] 2 2
[10 20 30] 3 4
[10 20 30 40] 4 4
[10 20 30 40 50] 5 8

در حالی که خوب است که برش‌ها به طور خودکار رشد می‌کنند، خیلی کارآمدتر است که یک بار اندازه آنها را تعیین کنید. اگر می‌دانید چند چیز قرار است در یک برش بگذارید، آن را با ظرفیت اولیه صحیح ایجاد کنید. این کار را با تابع make انجام می‌دهید.

make

تا به حال دو روش برای اعلان یک slice دیده‌اید: استفاده از slice literal یا مقدار صفر nil. اگرچه مفید هستند، اما هیچ‌کدام از این روش‌ها به شما اجازه نمی‌دهند یک slice خالی ایجاد کنید که از قبل طول یا ظرفیت مشخصی داشته باشد. این وظیفه تابع داخلی make است. این تابع به شما اجازه می‌دهد نوع، طول و به صورت اختیاری، ظرفیت را مشخص کنید. بیایید نگاهی بیندازیم:

x := make([]int, 5)

این کد یک slice از نوع int با طول ۵ و ظرفیت ۵ ایجاد می‌کند. از آنجایی که طول آن ۵ است، المان‌های x0 تا x4 المان‌های معتبری هستند و همه آنها با مقدار ۰ مقداردهی اولیه شده‌اند.

یکی از اشتباهات رایج مبتدیان این است که سعی می‌کنند آن المان‌های اولیه را با استفاده از append پر کنند:

x := make([]int, 5)
x = append(x, 10)

عدد ۱۰ در انتهای slice قرار می‌گیرد، پس از مقادیر صفر در المان‌های ۰ تا ۴، زیرا append همیشه طول یک slice را افزایش می‌دهد. مقدار x اکنون 0 0 0 0 0 10 است، با طول ۶ و ظرفیت ۱۰ (ظرفیت به محض اضافه شدن المان ششم دو برابر شد).

همچنین می‌توانید ظرفیت اولیه را با make مشخص کنید:

x := make([]int, 5, 10)

این کد یک slice از نوع int با طول ۵ و ظرفیت ۱۰ ایجاد می‌کند.

می‌توانید یک slice با طول صفر اما ظرفیت بیشتر از صفر نیز ایجاد کنید:

x := make([]int, 0, 10)

در این حالت، یک slice غیر nil با طول ۰ اما ظرفیت ۱۰ دارید. از آنجایی که طول آن ۰ است، نمی‌توانید مستقیماً به آن ایندکس کنید، اما می‌توانید مقادیر را به آن append کنید:

x := make([]int, 0, 10)
x = append(x, 5,6,7,8)

مقدار x اکنون 5 6 7 8 است، با طول ۴ و ظرفیت ۱۰.

هرگز ظرفیتی کمتر از طول مشخص نکنید! انجام این کار با یک ثابت یا literal عددی خطای زمان کامپایل است. اگر از متغیر برای مشخص کردن ظرفیتی کمتر از طول استفاده کنید، برنامه شما در زمان اجرا panic خواهد کرد.

خالی کردن یک Slice

Go 1.21 تابع clear را اضافه کرد که یک slice می‌گیرد و همه المان‌های slice را به مقدار صفر آنها تنظیم می‌کند. طول slice بدون تغییر باقی می‌ماند. کد زیر:

s := []string{"first", "second", "third"}
fmt.Println(s, len(s))
clear(s)
fmt.Println(s, len(s))

خروجی زیر را چاپ می‌کند:

[first second third] 3
[  ] 3

(به یاد داشته باشید، مقدار صفر برای string یک رشته خالی "" است!)

اعلان Slice شما

حالا که همه این روش‌های ایجاد slice را دیده‌اید، چگونه انتخاب می‌کنید از کدام سبک اعلان slice استفاده کنید؟ هدف اصلی کمینه کردن تعداد دفعاتی است که slice نیاز به رشد دارد. اگر احتمال دارد که slice اصلاً نیازی به رشد نداشته باشد، از اعلان var بدون مقدار تخصیص داده شده برای ایجاد یک nil slice استفاده کنید، همانطور که در مثال ۳-۲ نشان داده شده است.

مثال ۳-۲. اعلان slice ای که ممکن است nil باقی بماند

var data []int

می‌توانید یک slice با استفاده از slice literal خالی ایجاد کنید:

var x = []int{}

این کد یک slice با طول صفر و ظرفیت صفر ایجاد می‌کند. این به طور گیج‌کننده‌ای با nil slice متفاوت است. به دلایل پیاده‌سازی، مقایسه یک slice با طول صفر با nil مقدار false برمی‌گرداند، در حالی که مقایسه یک nil slice با nil مقدار true برمی‌گرداند. برای سادگی، nil slice ها را ترجیح دهید. یک slice با طول صفر تنها هنگام تبدیل یک slice به JSON مفید است. این موضوع را بیشتر در "encoding/json" در صفحه ۳۲۷ بررسی خواهید کرد.

اگر مقادیر شروعی دارید، یا اگر مقادیر slice تغییر نخواهند کرد، آنگاه slice literal انتخاب خوبی است (مثال ۳-۳ را ببینید).

مثال ۳-۳. اعلان slice با مقادیر پیش‌فرض

data := []int{2, 4, 6, 8} // اعدادی که قدردانی می‌کنیم

اگر ایده خوبی از اندازه مورد نیاز slice دارید، اما نمی‌دانید آن مقادیر هنگام نوشتن برنامه چه خواهند بود، از make استفاده کنید. سپس سوال این است که آیا باید طول غیر صفری در فراخوانی make مشخص کنید یا طول صفر و ظرفیت غیر صفر مشخص کنید. سه احتمال وجود دارد:

• اگر از slice به عنوان buffer استفاده می‌کنید (این را در "io and Friends" در صفحه ۳۱۹ خواهید دید)، آنگاه طول غیر صفری مشخص کنید.

• اگر مطمئن هستید که اندازه دقیق مورد نظر را می‌دانید، می‌توانید طول را مشخص کنید و به slice ایندکس کنید تا مقادیر را تنظیم کنید. این کار اغلب هنگام تبدیل مقادیر در یک slice و ذخیره آنها در slice دوم انجام می‌شود. نکته منفی این رویکرد این است که اگر اندازه را اشتباه حدس بزنید، یا با مقادیر صفر در انتهای slice مواجه خواهید شد یا با panic ناشی از تلاش برای دسترسی به المان‌هایی که وجود ندارند.

• در موقعیت‌های دیگر، از make با طول صفر و ظرفیت مشخص شده استفاده کنید. این به شما اجازه می‌دهد از append برای افزودن آیتم‌ها به slice استفاده کنید. اگر تعداد آیتم‌ها کمتر از حد انتظار باشد، مقدار صفر اضافی در انتها نخواهید داشت. اگر تعداد آیتم‌ها بیشتر باشد، کد شما panic نخواهد کرد.

جامعه Go بین رویکرد دوم و سوم تقسیم شده است. شخصاً ترجیح می‌دهم از append با slice ای که با طول صفر مقداردهی اولیه شده استفاده کنم. ممکن است در برخی موقعیت‌ها کندتر باشد، اما احتمال کمتری برای ایجاد باگ دارد.

append همیشه طول یک slice را افزایش می‌دهد! اگر طول slice را با استفاده از make مشخص کرده‌اید، مطمئن شوید که واقعاً قصد append کردن به آن را دارید، وگرنه ممکن است با مجموعه‌ای از مقادیر صفر غافلگیرکننده در ابتدای slice خود مواجه شوید.

برش کردن Slice‌ها

یک عبارت slice، یک slice جدید از یک slice موجود ایجاد می‌کند. این عبارت داخل براکت نوشته می‌شود و شامل یک offset شروع و یک offset پایان است که با دونقطه (:) از هم جدا شده‌اند. offset شروع، اولین موقعیت در slice است که در slice جدید شامل می‌شود، و offset پایان، یکی بعد از آخرین موقعیت برای شامل کردن است. اگر offset شروع را حذف کنید، 0 در نظر گرفته می‌شود. به همین ترتیب، اگر offset پایان را حذف کنید، انتهای slice جایگزین می‌شود. شما می‌توانید با اجرای کد در مثال 3-4 در The Go Playground یا در دایرکتوری sample_code/slicing_slices در repository فصل 3، ببینید که این چگونه کار می‌کند.

مثال 3-4. برش کردن slice‌ها

x := []string{"a", "b", "c", "d"}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

خروجی زیر را ارائه می‌دهد:

x: [a b c d]
y: [a b]
z: [b c d]
d: [b c]
e: [a b c d]

وقتی یک slice از یک slice می‌گیرید، شما کپی از داده‌ها نمی‌سازید. در عوض، اکنون دو متغیر دارید که حافظه را به اشتراک می‌گذارند. این به این معناست که تغییرات در یک عنصر slice، همه slice‌هایی که آن عنصر را به اشتراک می‌گذارند را تحت تأثیر قرار می‌دهد. بیایید ببینیم وقتی مقادیر را تغییر می‌دهید چه اتفاقی می‌افتد. شما می‌توانید کد موجود در مثال 3-5 را در The Go Playground یا در دایرکتوری sample_code/slice_share_storage در repository فصل 3 اجرا کنید.

مثال 3-5. Slice‌ها با ذخیره‌سازی همپوشان

x := []string{"a", "b", "c", "d"}
y := x[:2]
z := x[1:]
x[1] = "y"
y[0] = "x"
z[1] = "z"
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

خروجی زیر را دریافت می‌کنید:

x: [x y z d]
y: [x y]
z: [y z d]

تغییر x هم y و هم z را تغییر داد، در حالی که تغییرات در y و z، x را تغییر داد.

Slice کردن slice‌ها زمانی که با append ترکیب می‌شود، بیشتر گیج‌کننده می‌شود. کد موجود در مثال 3-6 را در The Go Playground یا در دایرکتوری sample_code/slice_append_storage در repository فصل 3 امتحان کنید.

مثال 3-6. append باعث گیج‌کننده‌تر شدن slice‌های همپوشان می‌شود

x := []string{"a", "b", "c", "d"}
y := x[:2]
fmt.Println(cap(x), cap(y))
y = append(y, "z")
fmt.Println("x:", x)
fmt.Println("y:", y)

اجرای این کد خروجی زیر را ارائه می‌دهد:

4 4
x: [a b z d]
y: [a b z]

چه اتفاقی می‌افتد؟ هر زمان که یک slice از slice دیگر می‌گیرید، ظرفیت (capacity) subslice برابر با ظرفیت slice اصلی منهای offset شروع subslice در داخل slice اصلی تنظیم می‌شود. این به این معناست که عناصر slice اصلی فراتر از انتهای subslice، شامل ظرفیت استفاده‌نشده، توسط هر دو slice به اشتراک گذاشته می‌شوند.

وقتی slice y را از x می‌سازید، طول (length) روی 2 تنظیم می‌شود، اما ظرفیت روی 4 تنظیم می‌شود، همان x. از آنجا که ظرفیت 4 است، append کردن به انتهای y، مقدار را در موقعیت سوم x قرار می‌دهد.

این رفتار، سناریوهای عجیبی ایجاد می‌کند، با slice‌های متعدد که append می‌کنند و داده‌های یکدیگر را رونویسی می‌کنند. ببینید آیا می‌توانید حدس بزنید کد موجود در مثال 3-7 چه چیزی چاپ می‌کند، سپس آن را در The Go Playground یا در دایرکتوری sample_code/confusing_slices در repository فصل 3 اجرا کنید تا ببینید آیا درست حدس زده‌اید.

مثال 3-7. slice‌های حتی گیج‌کننده‌تر

x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2]
z := x[2:]
fmt.Println(cap(x), cap(y), cap(z))
y = append(y, "i", "j", "k")
x = append(x, "x")
z = append(z, "y")
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

برای جلوگیری از موقعیت‌های پیچیده slice، شما باید یا هرگز از append با subslice استفاده نکنید یا مطمئن شوید که append باعث رونویسی نمی‌شود با استفاده از عبارت slice کامل. این کمی عجیب است، اما واضح می‌کند که چقدر حافظه بین slice والد و subslice به اشتراک گذاشته می‌شود. عبارت slice کامل شامل بخش سومی است که آخرین موقعیت در ظرفیت slice والد که برای subslice در دسترس است را نشان می‌دهد. offset شروع را از این عدد کم کنید تا ظرفیت subslice را بدست آورید. مثال 3-8 چهار خط اول از مثال قبلی را نشان می‌دهد که برای استفاده از عبارات slice کامل تغییر یافته است.

مثال 3-8. عبارت slice کامل در برابر append محافظت می‌کند

x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2:2]
z := x[2:4:4]

این کد را در The Go Playground یا در دایرکتوری sample_code/full_slice_expression در repository فصل 3 امتحان کنید. هم y و هم z ظرفیت 2 دارند. چون شما ظرفیت subslice‌ها را به طول‌هایشان محدود کردید، append کردن عناصر اضافی به y و z، slice‌های جدیدی ایجاد کرد که با slice‌های دیگر تعامل نداشتند. بعد از اجرای این کد، x برابر a b c d x تنظیم می‌شود، y برابر a b i j k تنظیم می‌شود، و z برابر c d y تنظیم می‌شود.

هشدار: هنگام گرفتن slice از یک slice مراقب باشید! هر دو slice حافظه یکسانی را به اشتراک می‌گذارند، و تغییرات در یکی در دیگری منعکس می‌شود. از تغییر slice‌ها بعد از اینکه slice شده‌اند یا اگر توسط slicing تولید شده‌اند، اجتناب کنید. از عبارت slice سه‌قسمتی استفاده کنید تا از به اشتراک گذاشتن ظرفیت بین slice‌ها توسط append جلوگیری کنید.

کپی

اگر نیاز دارید که slice‌ای ایجاد کنید که مستقل از اصلی باشد، از تابع داخلی copy استفاده کنید. بیایید نگاهی به یک مثال ساده بیندازیم که می‌توانید آن را در Go Playground یا در دایرکتوری sample_code/copy_slice در مخزن فصل ۳ اجرا کنید:

x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num)

این خروجی را دریافت می‌کنید:

[1 2 3 4] 4

تابع copy دو پارامتر می‌گیرد. اولی slice مقصد است و دومی slice منبع. این تابع تا آنجا که می‌تواند مقادیر را از منبع به مقصد کپی می‌کند، محدود به هر کدام از slice‌ها که کوچک‌تر باشد، و تعداد عناصر کپی شده را برمی‌گرداند. ظرفیت x و y مهم نیست؛ مهم طول آن‌هاست.

همچنین می‌توانید زیرمجموعه‌ای از یک slice را کپی کنید. کد زیر دو عنصر اول یک slice چهار عنصری را در یک slice دو عنصری کپی می‌کند:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
num := copy(y, x)

متغیر y روی 1 2 تنظیم می‌شود و num روی ۲.

همچنین می‌توانید از وسط slice منبع کپی کنید:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
copy(y, x[2:])

شما عناصر سوم و چهارم x را با گرفتن یک slice از slice کپی می‌کنید. همچنین توجه کنید که خروجی copy را به متغیری اختصاص نمی‌دهید. اگر به تعداد عناصر کپی شده نیاز ندارید، نیازی به اختصاص آن ندارید.

تابع copy به شما اجازه می‌دهد بین دو slice که بخش‌های همپوشان از یک slice زیرین را پوشش می‌دهند، کپی کنید:

x := []int{1, 2, 3, 4}
num := copy(x[:3], x[1:])
fmt.Println(x, num)

در این مورد، شما سه مقدار آخر در x را روی سه مقدار اول x کپی می‌کنید. این 2 3 4 4 3 را چاپ می‌کند.

می‌توانید از copy با آرایه‌ها استفاده کنید با گرفتن slice از آرایه. می‌توانید آرایه را منبع یا مقصد کپی قرار دهید. می‌توانید کد زیر را در Go Playground یا در دایرکتوری sample_code/copy_array در مخزن فصل ۳ امتحان کنید:

x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y)
copy(d[:], x)
fmt.Println(d)

اولین فراخوانی copy دو مقدار اول در آرایه d را در slice y کپی می‌کند. دومی تمام مقادیر در slice x را در آرایه d کپی می‌کند. این خروجی زیر را تولید می‌کند:

[5 6]
[1 2 3 4]

تبدیل آرایه‌ها به Slice‌ها

Slice‌ها تنها چیزی نیستند که می‌توانید slice کنید. اگر آرایه‌ای دارید، می‌توانید با استفاده از عبارت slice، یک slice از آن بگیرید. این روش مفیدی برای پل زدن یک آرایه به تابعی است که فقط slice‌ها را می‌پذیرد. برای تبدیل کل آرایه به slice، از نحو : استفاده کنید:

xArray := [4]int{5, 6, 7, 8}
xSlice := xArray[:]

همچنین می‌توانید زیرمجموعه‌ای از آرایه را به slice تبدیل کنید:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]

توجه داشته باشید که گرفتن slice از آرایه همان خصوصیات اشتراک حافظه را دارد که گرفتن slice از slice دارد. اگر کد زیر را در Go Playground یا در دایرکتوری sample_code/slice_array_memory در مخزن فصل ۳ اجرا کنید:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
x[0] = 10
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

این خروجی را دریافت می‌کنید:

x: [10 6 7 8]
y: [10 6]
z: [7 8]

تبدیل Slice‌ها به آرایه‌ها

از تبدیل نوع برای ساخت متغیر آرایه از slice استفاده کنید. می‌توانید کل slice را به آرایه‌ای از همان نوع تبدیل کنید، یا می‌توانید آرایه‌ای از زیرمجموعه slice ایجاد کنید.

وقتی slice را به آرایه تبدیل می‌کنید، داده‌های موجود در slice به حافظه جدید کپی می‌شوند. یعنی تغییرات در slice تأثیری بر آرایه نخواهد داشت و برعکس.

کد زیر:

xSlice := []int{1, 2, 3, 4}
xArray := [4]int(xSlice)
smallArray := [2]int(xSlice)
xSlice[0] = 10
fmt.Println(xSlice)
fmt.Println(xArray)
fmt.Println(smallArray)

این را چاپ می‌کند:

[10 2 3 4]
[1 2 3 4]
[1 2]

اندازه آرایه باید در زمان کامپایل مشخص شود. استفاده از ... در تبدیل نوع slice به آرایه خطای کامپایل است.

در حالی که اندازه آرایه می‌تواند کوچک‌تر از اندازه slice باشد، نمی‌تواند بزرگ‌تر باشد. متأسفانه، کامپایلر نمی‌تواند این را تشخیص دهد و کد شما در زمان اجرا panic خواهد کرد اگر اندازه آرایه‌ای بزرگ‌تر از طول (نه ظرفیت) slice مشخص کنید. کد زیر:

panicArray := [5]int(xSlice)
fmt.Println(panicArray)

در زمان اجرا با این پیام panic می‌کند:

panic: runtime error: cannot convert slice with length 4 to array
     or pointer to array with length 5

هنوز در مورد pointer‌ها صحبت نکرده‌ام، اما می‌توانید از تبدیل نوع برای تبدیل slice به pointer به آرایه نیز استفاده کنید:

xSlice := []int{1,2,3,4}
xArrayPointer := (*[4]int)(xSlice)

پس از تبدیل slice به pointer آرایه، حافظه بین این دو اشتراک دارند. تغییر یکی باعث تغییر دیگری می‌شود:

xSlice[0] = 10
xArrayPointer[1] = 20
fmt.Println(xSlice) // prints [10 20 3 4]
fmt.Println(xArrayPointer) // prints &[10 20 3 4]

Pointer‌ها در فصل ۶ پوشش داده می‌شوند.

می‌توانید همه تبدیلات نوع آرایه را در Go Playground یا در دایرکتوری sample_code/array_conversion در مخزن فصل ۳ امتحان کنید.

در "آرایه‌ها—خیلی سخت برای استفاده مستقیم" در صفحه ۳۷، اشاره کردم که نمی‌توانید از آرایه‌ها به عنوان پارامترهای تابع استفاده کنید وقتی اندازه آرایه‌ای که وارد می‌شود ممکن است متغیر باشد. از نظر فنی، می‌توانید این محدودیت را با تبدیل آرایه به slice، تبدیل slice به آرایه‌ای با اندازه متفاوت، و سپس وارد کردن آرایه دوم به تابع دور بزنید. آرایه دوم باید کوتاه‌تر از آرایه اول باشد، وگرنه برنامه شما panic خواهد کرد. در حالی که این ممکن است در شرایط اضطراری مفید باشد، اگر خود را مکرراً این کار را انجام می‌دهید، قویاً توصیه می‌کنم API تابع خود را تغییر دهید تا slice را به جای آرایه بپذیرد.

رشته‌ها و رون‌ها و بایت‌ها

حالا که درباره برش‌ها (slices) صحبت کرده‌ام، می‌توانیم دوباره به رشته‌ها نگاه کنیم. ممکن است فکر کنید که یک رشته در Go از رون‌ها (runes) ساخته شده است، اما چنین نیست. در زیر پوشش، Go از یک توالی از بایت‌ها برای نمایش یک رشته استفاده می‌کند. این بایت‌ها لازم نیست در هیچ کدگذاری کاراکتر خاصی باشند، اما چندین تابع کتابخانه Go (و حلقه for-range که در فصل بعد بحث می‌کنم) فرض می‌کنند که یک رشته از یک توالی از نقاط کد کدگذاری شده UTF-8 تشکیل شده است.

طبق مشخصات زبان، کد منبع Go همیشه در UTF-8 نوشته می‌شود. مگر اینکه از escape های هگزادسیمال در یک literal رشته استفاده کنید، literal های رشته شما در UTF-8 نوشته شده‌اند.

درست همانطور که می‌توانید یک مقدار واحد را از یک آرایه یا برش استخراج کنید، می‌توانید یک مقدار واحد را از یک رشته با استفاده از یک عبارت ایندکس استخراج کنید:

var s string = "Hello there"
var b byte = s[6]

مانند آرایه‌ها و برش‌ها، ایندکس‌های رشته بر پایه صفر هستند؛ در این مثال، b مقدار عددی موقعیت هفتم در s را اختصاص می‌دهد، که 116 است (مقدار UTF-8 حرف کوچک t).

نماد عبارت برش که با آرایه‌ها و برش‌ها استفاده کردید همچنین با رشته‌ها کار می‌کند:

var s string = "Hello there"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

این "o t" را به s2، "Hello" را به s3، و "there" را به s4 اختصاص می‌دهد. می‌توانید این کد را در The Go Playground یا در دایرکتوری sample_code/string_slicing در مخزن فصل 3 امتحان کنید.

در حالی که مفید است که Go به شما اجازه می‌دهد از نماد برش برای ساخت زیررشته‌ها و از نماد ایندکس برای استخراج ورودی‌های فردی از یک رشته استفاده کنید، باید هنگام انجام این کار مراقب باشید. از آنجا که رشته‌ها تغییرناپذیر هستند، مشکلات تغییری که برش‌های برش‌ها دارند را ندارند. اما مشکل متفاوتی وجود دارد. یک رشته از یک توالی از بایت‌ها تشکیل شده است، در حالی که یک نقطه کد در UTF-8 می‌تواند از یک تا چهار بایت طول داشته باشد. مثال قبلی کاملاً از نقاط کدی که در UTF-8 یک بایت طول دارند تشکیل شده بود، بنابراین همه چیز همانطور که انتظار می‌رفت کار کرد. اما هنگام کار با زبان‌های غیر از انگلیسی یا با ایموجی‌ها، با نقاط کدی مواجه می‌شوید که در UTF-8 چند بایت طول دارند:

var s string = "Hello ☀"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]

در این مثال، s3 هنوز برابر با "Hello" خواهد بود. متغیر s4 روی ایموجی خورشید تنظیم می‌شود. اما s2 روی "o ☀" تنظیم نمی‌شود. به جای آن، "o �" را دریافت می‌کنید. این به این دلیل است که فقط بایت اول نقطه کد ایموجی خورشید را کپی کردید، که به تنهایی یک نقطه کد معتبر نیست.

Go به شما اجازه می‌دهد یک رشته را به تابع داخلی len ارسال کنید تا طول رشته را پیدا کنید. با توجه به اینکه عبارات ایندکس و برش رشته موقعیت‌ها را بر حسب بایت محاسبه می‌کنند، تعجب‌آور نیست که طول برگردانده شده، طول بر حسب بایت است، نه بر حسب نقاط کد:

var s string = "Hello ☀"
fmt.Println(len(s))

این کد 10 را چاپ می‌کند، نه 7، زیرا نمایش ایموجی خورشید با صورت خندان در UTF-8 چهار بایت طول می‌کشد. می‌توانید این مثال‌های ایموجی خورشید را در The Go Playground یا در دایرکتوری sample_code/sun_slicing در مخزن فصل 3 اجرا کنید.

اگرچه Go به شما اجازه می‌دهد از syntax برش و ایندکس با رشته‌ها استفاده کنید، باید از آن فقط زمانی استفاده کنید که بدانید رشته شما فقط شامل کاراکترهایی است که یک بایت جا می‌گیرند.

به دلیل این رابطه پیچیده میان رون‌ها، رشته‌ها، و بایت‌ها، Go برخی تبدیل‌های نوع جالب میان این انواع دارد. یک رون یا بایت واحد می‌تواند به یک رشته تبدیل شود:

var a rune    = 'x'
var s string  = string(a)
var b byte    = 'y'
var s2 string = string(b)

یک باگ رایج برای توسعه‌دهندگان جدید Go این است که سعی می‌کنند یک int را با استفاده از تبدیل نوع به یک رشته تبدیل کنند:

var x int = 65
var y = string(x)
fmt.Println(y)

این منجر به این می‌شود که y مقدار "A" داشته باشد، نه "65". از Go 1.15، go vet تبدیل نوع به رشته از هر نوع عدد صحیح غیر از rune یا byte را مسدود می‌کند.

یک رشته می‌تواند به یک برش از بایت‌ها یا یک برش از رون‌ها تبدیل شود و برعکس. مثال 3-9 را در The Go Playground یا در دایرکتوری sample_code/string_to_slice در مخزن فصل 3 امتحان کنید.

مثال 3-9. تبدیل رشته‌ها به برش‌ها

var s string = "Hello, ☀"
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(bs)
fmt.Println(rs)

وقتی این کد را اجرا می‌کنید، موارد زیر را می‌بینید:

[72 101 108 108 111 44 32 240 159 140 158]
[72 101 108 108 111 44 32 127774]

خط خروجی اول رشته تبدیل شده به بایت‌های UTF-8 را دارد. دومی رشته تبدیل شده به رون‌ها را دارد.

بیشتر داده‌ها در Go به عنوان یک توالی از بایت‌ها خوانده و نوشته می‌شوند، بنابراین رایج‌ترین تبدیل‌های نوع رشته به یک طرف و آن طرف با یک برش از بایت‌ها است. برش‌های رون‌ها غیررایج هستند.

UTF-8

UTF-8 رایج‌ترین کدگذاری مورد استفاده برای یونیکد است. یونیکد از چهار بایت (32 بیت) برای نمایش هر نقطه کد استفاده می‌کند، که نام فنی هر کاراکتر و اصلاح‌کننده است. با توجه به این، ساده‌ترین راه برای نمایش نقاط کد یونیکد ذخیره چهار بایت برای هر نقطه کد است. این UTF-32 نامیده می‌شود. بیشتر استفاده نمی‌شود زیرا فضای زیادی هدر می‌دهد. به دلیل جزئیات پیاده‌سازی یونیکد، 11 تا از 32 بیت همیشه صفر هستند. کدگذاری رایج دیگر UTF-16 است، که از یک یا دو توالی 16 بیتی (2 بایتی) برای نمایش هر نقطه کد استفاده می‌کند. این هم هدردهنده است؛ بسیاری از محتویات دنیا با استفاده از نقاط کدی که در یک بایت جا می‌گیرند نوشته می‌شوند. و اینجاست که UTF-8 وارد می‌شود.

UTF-8 هوشمند است. به شما اجازه می‌دهد از یک بایت واحد برای نمایش کاراکترهای یونیکد که مقادیرشان زیر 128 است (که شامل تمام حروف، اعداد، و علائم نگارشی که معمولاً در انگلیسی استفاده می‌شوند) استفاده کنید، اما تا حداکثر چهار بایت برای نمایش نقاط کد یونیکد با مقادیر بزرگتر گسترش می‌یابد. نتیجه این است که بدترین حالت برای UTF-8 همان استفاده از UTF-32 است. UTF-8 ویژگی‌های خوب دیگری هم دارد. برخلاف UTF-32 و UTF-16، نیازی نیست نگران little-endian در مقابل big-endian باشید. همچنین به شما اجازه می‌دهد به هر بایت در یک توالی نگاه کنید و بگویید آیا در ابتدای یک توالی UTF-8 هستید یا جایی در وسط. این یعنی نمی‌توانید تصادفاً یک کاراکتر را اشتباه بخوانید.

تنها نقطه منفی این است که نمی‌توانید به طور تصادفی به یک رشته کدگذاری شده با UTF-8 دسترسی داشته باشید. در حالی که می‌توانید تشخیص دهید که در وسط یک کاراکتر هستید، نمی‌توانید بگویید چند کاراکتر داخل آن هستید. باید از ابتدای رشته شروع کنید و بشمارید. Go نیازی به نوشته شدن یک رشته در UTF-8 ندارد اما قویاً آن را تشویق می‌کند. خواهید دید که چگونه با رشته‌های UTF-8 در فصل‌های آینده کار کنید.

حقیقت جالب: UTF-8 در سال 1992 توسط Ken Thompson و Rob Pike، دو نفر از خالقان Go، اختراع شد.

به جای استفاده از عبارات برش و ایندکس با رشته‌ها، باید زیررشته‌ها و نقاط کد را از رشته‌ها با استفاده از توابع موجود در بسته‌های strings و unicode/utf8 در کتابخانه استاندارد استخراج کنید. در فصل بعد، خواهید دید که چگونه از یک حلقه for-range برای تکرار روی نقاط کد در یک رشته استفاده کنید.

نگاشت‌ها (Maps)

اسلایس‌ها زمانی مفید هستند که داده‌های متوالی دارید. مانند اکثر زبان‌ها، Go یک نوع داده داخلی برای موقعیت‌هایی که می‌خواهید یک مقدار را به مقدار دیگری مرتبط کنید، فراهم می‌کند. نوع map به صورت map[keyType]valueType نوشته می‌شود. بیایید نگاهی به چند روش برای اعلان نگاشت‌ها بیندازیم. ابتدا، می‌توانید از اعلان var برای ایجاد متغیر نگاشت که به مقدار صفر آن تنظیم شده است، استفاده کنید:

var nilMap map[string]int

در این مورد، nilMap به عنوان نگاشتی با کلیدهای string و مقادیر int اعلان شده است. مقدار صفر برای یک نگاشت nil است. یک نگاشت nil طولی برابر با ۰ دارد. تلاش برای خواندن یک نگاشت nil همیشه مقدار صفر نوع مقدار نگاشت را برمی‌گرداند. با این حال، تلاش برای نوشتن در متغیر نگاشت nil باعث panic می‌شود.

می‌توانید از اعلان := برای ایجاد متغیر نگاشت با اختصاص یک لیترال نگاشت به آن استفاده کنید:

totalWins := map[string]int{}

در این مورد، شما از یک لیترال نگاشت خالی استفاده می‌کنید. این همان چیز نیست که یک نگاشت nil باشد. طولی برابر با ۰ دارد، اما می‌توانید به نگاشتی که به آن لیترال نگاشت خالی اختصاص داده شده، بخوانید و بنویسید. در ادامه نمونه‌ای از لیترال نگاشت غیرخالی آورده شده است:

teams := map[string][]string {
    "Orcas": []string{"Fred", "Ralph", "Bijou"},
    "Lions": []string{"Sarah", "Peter", "Billie"},
    "Kittens": []string{"Waldo", "Raul", "Ze"},
}

بدنه لیترال نگاشت به صورت کلید، به دنبال آن دونقطه (:)، سپس مقدار نوشته می‌شود. کاما هر جفت کلید-مقدار در نگاشت را جدا می‌کند، حتی در آخرین خط. در این مثال، مقدار یک اسلایس از رشته‌ها است. نوع مقدار در یک نگاشت می‌تواند هر چیزی باشد. برای انواع کلیدها محدودیت‌هایی وجود دارد که کمی بعد درباره آن‌ها صحبت خواهم کرد.

اگر می‌دانید چند جفت کلید-مقدار قصد دارید در نگاشت قرار دهید اما مقادیر دقیق را نمی‌دانید، می‌توانید از make برای ایجاد نگاشت با اندازه پیش‌فرض استفاده کنید:

ages := make(map[int][]string, 10)

نگاشت‌هایی که با make ایجاد می‌شوند همچنان طولی برابر با ۰ دارند، و می‌توانند فراتر از اندازه اولیه مشخص شده رشد کنند.

نگاشت‌ها در چندین جهت شبیه اسلایس‌ها هستند:

• نگاشت‌ها به طور خودکار هنگامی که جفت‌های کلید-مقدار به آن‌ها اضافه می‌کنید، رشد می‌کنند.

• اگر می‌دانید چند جفت کلید-مقدار قصد دارید در یک نگاشت درج کنید، می‌توانید از make برای ایجاد نگاشت با اندازه اولیه مشخص استفاده کنید.

• ارسال یک نگاشت به تابع len تعداد جفت‌های کلید-مقدار موجود در نگاشت را به شما می‌گوید.

• مقدار صفر برای یک نگاشت nil است.

• نگاشت‌ها قابل مقایسه نیستند. می‌توانید بررسی کنید که آیا آن‌ها برابر با nil هستند، اما نمی‌توانید با استفاده از == یا != بررسی کنید که آیا دو نگاشت کلیدها و مقادیر یکسانی دارند یا متفاوت هستند.

کلید یک نگاشت می‌تواند هر نوع قابل مقایسه‌ای باشد. این به این معنی است که نمی‌توانید از اسلایس یا نگاشت به عنوان کلید برای یک نگاشت استفاده کنید.

چه زمانی باید از نگاشت استفاده کنید و چه زمانی باید از اسلایس استفاده کنید؟ باید از اسلایس‌ها برای فهرست‌های داده زمانی که داده‌ها باید به ترتیب پردازش شوند یا ترتیب عناصر مهم است، استفاده کنید.

نگاشت‌ها زمانی مفید هستند که نیاز دارید مقادیر را با استفاده از چیزی غیر از یک مقدار صحیح افزایشی، مانند یک نام، سازماندهی کنید.

نگاشت هش (Hash Map) چیست؟

در علوم کامپیوتر، نگاشت ساختار داده‌ای است که یک مقدار را به مقدار دیگری مرتبط می‌کند (یا نگاشت می‌کند). نگاشت‌ها را می‌توان به روش‌های مختلفی پیاده‌سازی کرد، هر کدام با مبادلات خاص خود. نگاشتی که در Go تعبیه شده است، یک نگاشت هش یا جدول هش است. اگر با این مفهوم آشنا نیستید، فصل ۵ در کتاب Grokking Algorithms نوشته Aditya Bhargava (انتشارات Manning) توضیح می‌دهد که جدول هش چیست و چرا آن‌قدر مفید هستند.

این عالی است که Go شامل پیاده‌سازی نگاشت هش به عنوان بخشی از runtime است، زیرا ساختن نگاشت هش شخصی سخت است و درست انجام دادن آن دشوار است. اگر می‌خواهید بیشتر درباره نحوه انجام این کار در Go بدانید، ویدیوی "Inside the Map Implementation" را ببینید، سخنرانی از GopherCon 2016 توسط Keith Randall.

Go نیازی ندارد (یا حتی اجازه نمی‌دهد) که الگوریتم هش یا تعریف برابری خود را تعریف کنید. در عوض، runtime Go که در هر برنامه Go کامپایل می‌شود، کدی دارد که الگوریتم‌های هش را برای تمام انواعی که مجاز به کلید بودن هستند، پیاده‌سازی می‌کند.

خواندن و نوشتن یک نگاشت

بیایید نگاهی به برنامه کوتاهی بیندازیم که یک نگاشت را اعلان می‌کند، در آن می‌نویسد، و از آن می‌خواند. می‌توانید برنامه موجود در مثال ۳-۱۰ را در The Go Playground یا در پوشه sample_code/map_read_write در مخزن فصل ۳ اجرا کنید.

مثال ۳-۱۰. استفاده از یک نگاشت

totalWins := map[string]int{}
totalWins["Orcas"] = 1
totalWins["Lions"] = 2
fmt.Println(totalWins["Orcas"])
fmt.Println(totalWins["Kittens"])
totalWins["Kittens"]++
fmt.Println(totalWins["Kittens"])
totalWins["Lions"] = 3
fmt.Println(totalWins["Lions"])

هنگامی که این برنامه را اجرا می‌کنید، خروجی زیر را خواهید دید:

1
0
1
3

شما مقدار را به کلید نگاشت با قرار دادن کلید درون براکت و استفاده از = برای مشخص کردن مقدار اختصاص می‌دهید، و مقدار اختصاص داده شده به کلید نگاشت را با قرار دادن کلید درون براکت می‌خوانید. توجه کنید که نمی‌توانید از := برای اختصاص مقدار به کلید نگاشت استفاده کنید.

هنگامی که سعی می‌کنید مقدار اختصاص داده شده به کلید نگاشتی را که هرگز تنظیم نشده بخوانید، نگاشت مقدار صفر نوع مقدار نگاشت را برمی‌گرداند. در این مورد، نوع مقدار int است، بنابراین ۰ را پس می‌گیرید. می‌توانید از عملگر ++ برای افزایش مقدار عددی کلید نگاشت استفاده کنید. از آنجا که نگاشت به طور پیش‌فرض مقدار صفر خود را برمی‌گرداند، این حتی زمانی که هیچ مقدار موجودی با کلید مرتبط نباشد، کار می‌کند.

اصطلاح comma ok

همان طور که دیده‌اید، نگاشت مقدار صفر را برمی‌گرداند اگر مقدار مرتبط با کلیدی که در نگاشت نیست را بخواهید. این هنگام پیاده‌سازی چیزهایی مانند شمارنده totalWins که قبلاً دیدید، مفید است. با این حال، گاهی اوقات واقعاً نیاز دارید بفهمید که آیا کلیدی در نگاشت وجود دارد. Go اصطلاح comma ok را برای تشخیص تفاوت بین کلیدی که با مقدار صفر مرتبط است و کلیدی که در نگاشت نیست، فراهم می‌کند:

m := map[string]int{
    "hello": 5,
    "world": 0,
}
v, ok := m["hello"]
fmt.Println(v, ok)

v, ok = m["world"]
fmt.Println(v, ok)

v, ok = m["goodbye"]
fmt.Println(v, ok)

به جای اینکه نتیجه خواندن نگاشت را به یک متغیر واحد اختصاص دهید، با اصطلاح comma ok نتایج خواندن نگاشت را به دو متغیر اختصاص می‌دهید. اولی مقدار مرتبط با کلید را دریافت می‌کند. دومین مقدار برگردانده شده یک bool است. معمولاً ok نامیده می‌شود. اگر ok برابر true باشد، کلید در نگاشت موجود است. اگر ok برابر false باشد، کلید موجود نیست. در این مثال، کد خروجی‌های 5 true، 0 true، و 0 false را چاپ می‌کند.

اصطلاح comma ok در Go زمانی استفاده می‌شود که می‌خواهید بین خواندن یک مقدار و دریافت مقدار صفر تفاوت قائل شوید. آن را دوباره هنگام خواندن از کانال‌ها در فصل ۱۲ و هنگام استفاده از ادعاهای نوع در فصل ۷ خواهید دید.

حذف از Maps

جفت‌های کلید-مقدار از یک map از طریق تابع داخلی delete حذف می‌شوند:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
delete(m, "hello")

تابع delete یک map و یک کلید می‌گیرد و سپس جفت کلید-مقدار با کلید مشخص شده را حذف می‌کند. اگر کلید در map موجود نباشد یا اگر map برابر nil باشد، هیچ اتفاقی نمی‌افتد. تابع delete هیچ مقداری برنمی‌گرداند.

خالی کردن یک Map

تابع clear که در "خالی کردن یک Slice" در صفحه ۴۴ دیدید، روی map ها نیز کار می‌کند. یک map پاک شده طول آن صفر تنظیم می‌شود، برخلاف یک slice پاک شده. کد زیر:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
fmt.Println(m, len(m))
clear(m)
fmt.Println(m, len(m))

خروجی زیر را چاپ می‌کند:

map[hello:5 world:10] 2
map[] 0

مقایسه Maps

Go 1.21 یک package به کتابخانه استاندارد اضافه کرد به نام maps که حاوی توابع کمکی برای کار با map ها است. در بخش "اضافه کردن Generics به کتابخانه استاندارد" در صفحه ۲۰۱ بیشتر در مورد این package خواهید آموخت. دو تابع در این package برای مقایسه اینکه آیا دو map برابر هستند مفید هستند: maps.Equal و maps.EqualFunc. آنها مشابه توابع slices.Equal و slices.EqualFunc هستند:

m := map[string]int{
    "hello": 5,
    "world": 10,
}
n := map[string]int{
    "world": 10,
    "hello": 5,
}
fmt.Println(maps.Equal(m, n)) // prints true

استفاده از Maps به عنوان Sets

بسیاری از زبان‌ها یک set در کتابخانه استاندارد خود دارند. set نوع داده‌ای است که تضمین می‌کند حداکثر یکی از هر مقدار وجود داشته باشد، اما تضمین نمی‌کند که مقادیر در ترتیب خاصی باشند. بررسی اینکه آیا عنصری در یک set هست سریع است، صرف نظر از اینکه چند عنصر در set باشد. (بررسی اینکه آیا عنصری در یک slice هست زمان بیشتری می‌برد، هرچه عناصر بیشتری به slice اضافه کنید.)

Go شامل set نمی‌شود، اما می‌توانید از یک map برای شبیه‌سازی برخی از ویژگی‌های آن استفاده کنید. از کلید map برای نوعی که می‌خواهید در set قرار دهید استفاده کنید و از bool برای مقدار استفاده کنید. کد در مثال ۳-۱۱ این مفهوم را نشان می‌دهد. می‌توانید آن را در The Go Playground یا در دایرکتوری sample_code/map_set در مخزن فصل ۳ اجرا کنید.

مثال ۳-۱۱. استفاده از map به عنوان set

intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = true
}
fmt.Println(len(vals), len(intSet))
fmt.Println(intSet[5])
fmt.Println(intSet[500])
if intSet[100] {
    fmt.Println("100 is in the set")
}

شما یک set از int ها می‌خواهید، بنابراین یک map ایجاد می‌کنید که کلیدهای آن از نوع int و مقادیر آن از نوع bool هستند. با استفاده از حلقه for-range (که در بخش "دستور for-range" در صفحه ۷۶ بحث می‌کنم) روی مقادیر در vals تکرار می‌کنید تا آنها را در intSet قرار دهید و هر int را با مقدار boolean برابر true مرتبط می‌کنید.

ما ۱۱ مقدار در intSet نوشتیم، اما طول intSet برابر ۸ است، چون نمی‌توانید کلیدهای تکراری در یک map داشته باشید. اگر به دنبال ۵ در intSet بگردید، true برمی‌گرداند، چون کلیدی با مقدار ۵ وجود دارد. با این حال، اگر به دنبال ۵۰۰ یا ۱۰۰ در intSet بگردید، false برمی‌گرداند. این به این دلیل است که هیچ یک از این مقادیر را در intSet قرار نداده‌اید، که باعث می‌شود map مقدار صفر برای مقدار map برگرداند، و مقدار صفر برای bool برابر false است.

اگر به set هایی نیاز دارید که عملیاتی مانند اتحاد، تقاطع و تفریق ارائه دهند، می‌توانید یا خودتان یکی بنویسید یا از یکی از کتابخانه‌های شخص ثالث متعددی که این قابلیت را ارائه می‌دهند استفاده کنید. (در فصل ۱۰ بیشتر در مورد استفاده از کتابخانه‌های شخص ثالث خواهید آموخت.)

نکته:

برخی افراد ترجیح می‌دهند از struct{} برای مقدار استفاده کنند وقتی از map برای پیاده‌سازی set استفاده می‌شود. (در بخش بعدی در مورد struct ها بحث خواهم کرد.) مزیت این است که یک struct خالی صفر بایت استفاده می‌کند، در حالی که یک boolean یک بایت استفاده می‌کند.

نقطه ضعف این است که استفاده از struct{} کد شما را دست و پا گیرتر می‌کند. assignment کمتر واضحی دارید و باید از idiom comma ok برای بررسی اینکه آیا مقداری در set هست استفاده کنید:

intSet := map[int]struct{}{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = struct{}{}
}
if _, ok := intSet[5]; ok {
    fmt.Println("5 is in the set")
}

مگر اینکه set های بسیار بزرگی داشته باشید، تفاوت در استفاده از حافظه احتمالاً به اندازه کافی قابل توجه نخواهد بود تا بر نقاط ضعف غلبه کند.

ساختارها (Structs)

نقشه‌ها (Maps) روشی مناسب برای ذخیره برخی انواع داده‌ها هستند، اما محدودیت‌هایی دارند. آن‌ها یک API تعریف نمی‌کنند زیرا هیچ راهی برای محدود کردن یک نقشه به اجازه دادن تنها کلیدهای خاص وجود ندارد. همچنین، تمام مقادیر در یک نقشه باید از همان نوع باشند. به این دلایل، نقشه‌ها روش ایده‌آلی برای انتقال داده از تابع به تابع نیستند. زمانی که داده‌های مرتبطی دارید که می‌خواهید آن‌ها را با هم گروه‌بندی کنید، باید یک ساختار (struct) تعریف کنید.

اگر شما قبلاً یک زبان شی‌گرا می‌شناسید، ممکن است در مورد تفاوت بین کلاس‌ها و ساختارها کنجکاو باشید. تفاوت ساده است: Go کلاس ندارد، چون وراثت (inheritance) ندارد. این بدان معنا نیست که Go برخی از ویژگی‌های زبان‌های شی‌گرا را ندارد، بلکه کارها را کمی متفاوت انجام می‌دهد. در فصل ۷ درباره ویژگی‌های شی‌گرای Go بیشتر یاد خواهید گرفت.

اکثر زبان‌ها مفهومی شبیه به ساختار دارند، و نحوه‌ی نوشتار (syntax) که Go برای خواندن و نوشتن ساختارها استفاده می‌کند باید آشنا به نظر برسد:

type person struct {
    name string
    age  int
    pet  string
}

یک نوع ساختار با کلیدواژه type، نام نوع ساختار، کلیدواژه struct، و یک جفت آکولاد ({}) تعریف می‌شود. درون آکولادها، فیلدهای ساختار را فهرست می‌کنید. درست مانند آنکه در اعلان var نام متغیر را اول و نوع متغیر را دوم قرار می‌دهید، نام فیلد ساختار را اول و نوع فیلد ساختار را دوم قرار می‌دهید. همچنین توجه کنید که برخلاف literal های نقشه، هیچ کاما فیلدها را در اعلان ساختار جدا نمی‌کند.

می‌توانید یک نوع ساختار را درون یا بیرون یک تابع تعریف کنید. نوع ساختاری که درون یک تابع تعریف شده باشد تنها درون همان تابع قابل استفاده است. (در فصل ۵ درباره توابع بیشتر یاد خواهید گرفت.)

از نظر فنی، می‌توانید تعریف ساختار را به هر سطح بلوک محدود کنید. در فصل ۴ درباره بلوک‌ها بیشتر یاد خواهید گرفت.

زمانی که یک نوع ساختار اعلان شد، می‌توانید متغیرهایی از آن نوع تعریف کنید:

var fred person

اینجا ما از اعلان var استفاده می‌کنیم. از آنجا که هیچ مقداری به fred اختصاص داده نمی‌شود، مقدار صفر (zero value) برای نوع ساختار person دریافت می‌کند. یک ساختار با مقدار صفر، تمام فیلدهایش روی مقدار صفر فیلد تنظیم شده است.

یک literal ساختار نیز می‌تواند به یک متغیر اختصاص داده شود:

bob := person{}

برخلاف نقشه‌ها، هیچ تفاوتی بین اختصاص دادن یک literal ساختار خالی و اصلاً مقدار اختصاص ندادن وجود ندارد. هر دو تمام فیلدهای ساختار را به مقادیر صفرشان مقداردهی اولیه می‌کنند.

دو سبک برای literal ساختار غیرخالی وجود دارد. اول، یک literal ساختار می‌تواند به عنوان فهرستی از مقادیر جدا شده با کاما برای فیلدها درون آکولاد مشخص شود:

julia := person{
    "Julia",
    40,
    "cat",
}

هنگام استفاده از این فرمت literal ساختار، باید مقداری برای هر فیلد در ساختار مشخص شود، و مقادیر به فیلدها به ترتیبی که در تعریف ساختار اعلان شده‌اند اختصاص داده می‌شوند.

سبک دوم literal ساختار شبیه سبک literal نقشه است:

beth := person{
    age:  30,
    name: "Beth",
}

از نام‌های فیلدهای ساختار برای مشخص کردن مقادیر استفاده می‌کنید. این سبک مزایایی دارد. به شما اجازه می‌دهد فیلدها را به هر ترتیبی مشخص کنید، و نیازی نیست برای تمام فیلدها مقدار ارائه دهید. هر فیلدی که مشخص نشود روی مقدار صفرش تنظیم می‌شود.

نمی‌توانید دو سبک literal ساختار را ترکیب کنید: یا تمام فیلدها با نام مشخص می‌شوند، یا هیچ‌کدام. برای ساختارهای کوچک که همیشه تمام فیلدها مشخص می‌شوند، سبک ساده‌تر literal ساختار مناسب است. در موارد دیگر، از نام‌ها استفاده کنید. پرحرف‌تر است، اما مشخص می‌کند که چه مقداری به چه فیلدی اختصاص داده می‌شود بدون نیاز به مراجعه به تعریف ساختار. همچنین قابل نگهداری‌تر است. اگر ساختار را بدون استفاده از نام‌های فیلد مقداردهی اولیه کنید و نسخه آینده ساختار فیلدهای اضافی اضافه کند، کد شما دیگر کامپایل نخواهد شد.

یک فیلد در ساختار با نشان‌گذاری نقطه (dot notation) دسترسی پیدا می‌کند:

bob.name = "Bob"
fmt.Println(bob.name)

درست مانند آنکه از براکت‌ها برای خواندن و نوشتن به نقشه استفاده می‌کنید، از نشان‌گذاری نقطه‌دار برای خواندن و نوشتن به فیلدهای ساختار استفاده می‌کنید.

ساختارهای ناشناس (Anonymous Structs)

همچنین می‌توانید اعلان کنید که یک متغیر نوع ساختاری را پیاده‌سازی می‌کند بدون آنکه ابتدا نامی به نوع ساختار بدهید. این ساختار ناشناس نامیده می‌شود:

var person struct {
    name string
    age  int
    pet  string
}

person.name = "bob"
person.age = 50
person.pet = "dog"

pet := struct {
    name string
    kind string
}{
    name: "Fido",
    kind: "dog",
}

در این مثال، انواع متغیرهای person و pet ساختارهای ناشناس هستند. فیلدها را در یک ساختار ناشناس درست مانند آنچه برای نوع ساختار نام‌گذاری شده انجام می‌دهید اختصاص می‌دهید (و می‌خوانید). درست مانند آنکه می‌توانید نمونه‌ای از ساختار نام‌گذاری شده را با literal ساختار مقداردهی اولیه کنید، می‌توانید همین کار را برای ساختار ناشناس نیز انجام دهید.

ممکن است تعجب کنید که چه زمانی داشتن نوع داده‌ای که تنها با یک نمونه واحد مرتبط است مفید باشد. ساختارهای ناشناس در دو موقعیت رایج مفید هستند. اولی زمانی است که داده‌های خارجی را به ساختار تبدیل می‌کنید یا ساختار را به داده‌های خارجی (مانند JSON یا Protocol Buffers). این کار به ترتیب unmarshaling و marshaling داده نامیده می‌شود. در بخش "encoding/json" در صفحه ۳۲۷ یاد خواهید گرفت که چگونه این کار را انجام دهید.

نوشتن تست‌ها جای دیگری است که ساختارهای ناشناس ظاهر می‌شوند. هنگام نوشتن تست‌های جدول-محور (table-driven tests) در فصل ۱۵ از slice ای از ساختارهای ناشناس استفاده خواهید کرد.

مقایسه و تبدیل ساختارها (Structs)

اینکه یک struct قابل مقایسه باشد یا نه، به فیلدهای آن struct بستگی دارد. ساختارهایی که به طور کامل از انواع قابل مقایسه تشکیل شده‌اند، قابل مقایسه هستند؛ آن‌هایی که دارای فیلدهای slice یا map هستند، قابل مقایسه نیستند (همان‌طور که در فصل‌های بعدی خواهید دید، فیلدهای function و channel نیز مانع از قابل مقایسه بودن یک struct می‌شوند).

برخلاف Python یا Ruby، در Go هیچ متد جادویی وجود ندارد که بتوان آن را override کرد تا برابری را دوباره تعریف کرد و == و != را برای structs غیرقابل مقایسه کارآمد کرد. البته شما می‌توانید تابع خودتان را بنویسید که از آن برای مقایسه structs استفاده کنید.

همان‌طور که Go اجازه مقایسه بین متغیرهای انواع primitive مختلف را نمی‌دهد، Go همچنین اجازه مقایسه بین متغیرهایی که نماینده structs از انواع مختلف هستند را نمی‌دهد. Go به شما اجازه می‌دهد که تبدیل نوع (type conversion) از یک نوع struct به نوع دیگر انجام دهید، اگر فیلدهای هر دو struct دارای همان نام‌ها، ترتیب و انواع باشند. بیایید ببینیم این به چه معناست. با در نظر گیری این struct:

type firstPerson struct {
    name string
    age  int
}

شما می‌توانید از یک type conversion برای تبدیل یک نمونه از firstPerson به secondPerson استفاده کنید، اما نمی‌توانید از == برای مقایسه یک نمونه از firstPerson و یک نمونه از secondPerson استفاده کنید، زیرا آن‌ها انواع مختلفی هستند:

type secondPerson struct {
    name string
    age  int
}

شما نمی‌توانید یک نمونه از firstPerson را به thirdPerson تبدیل کنید، زیرا فیلدها در ترتیب متفاوتی قرار دارند:

type thirdPerson struct {
    age  int
    name string
}

شما نمی‌توانید یک نمونه از firstPerson را به fourthPerson تبدیل کنید زیرا نام فیلدها مطابقت ندارند:

type fourthPerson struct {
    firstName string
    age       int
}

در نهایت، شما نمی‌توانید یک نمونه از firstPerson را به fifthPerson تبدیل کنید زیرا یک فیلد اضافی وجود دارد:

type fifthPerson struct {
    name          string
    age           int
    favoriteColor string
}

Anonymous structs پیچیدگی کوچکی اضافه می‌کنند: اگر دو متغیر struct در حال مقایسه هستند و حداقل یکی دارای نوعی است که یک anonymous struct است، شما می‌توانید آن‌ها را بدون type conversion مقایسه کنید، اگر فیلدهای هر دو struct دارای همان نام‌ها، ترتیب و انواع باشند. همچنین می‌توانید بین انواع named و anonymous struct تخصیص (assign) انجام دهید اگر فیلدهای هر دو struct دارای همان نام‌ها، ترتیب و انواع باشند:

type firstPerson struct {
    name string
    age  int
}
f := firstPerson{
    name: "Bob",
    age:  50,
}
var g struct {
    name string
    age  int
}

// compiles -- can use = and == between identical named and anonymous structs
g = f
fmt.Println(f == g)

تمرین‌ها

تمرین‌های زیر آنچه را که درباره انواع composite در Go یاد گرفته‌اید، آزمایش خواهند کرد. می‌توانید راه‌حل‌ها را در دایرکتوری exercise_solutions در Chapter 3 Repository پیدا کنید.

  1. برنامه‌ای بنویسید که متغیری به نام greetings از نوع slice رشته‌ها با مقادیر زیر تعریف کند: "Hello"، "Hola"، "नमस्कार"، "こんにちは"، و "Привіт". یک subslice حاوی دو مقدار اول؛ یک subslice دوم با مقادیر دوم، سوم و چهارم؛ و یک subslice سوم با مقادیر چهارم و پنجم ایجاد کنید. هر چهار slice را چاپ کنید.
  2. برنامه‌ای بنویسید که متغیر رشته‌ای به نام message با مقدار "Hi 👩 and 👨" تعریف کند و چهارمین rune در آن را به عنوان یک کاراکتر چاپ کند، نه یک عدد.
  3. برنامه‌ای بنویسید که struct‌ای به نام Employee با سه فیلد تعریف کند: firstName، lastName و id. دو فیلد اول از نوع string هستند و فیلد آخر (id) از نوع int است. سه نمونه از این struct با هر مقداری که دوست دارید ایجاد کنید. اولی را با استفاده از سبک struct literal بدون نام‌ها، دومی را با استفاده از سبک struct literal با نام‌ها، و سومی را با یک اعلان var مقداردهی اولیه کنید. از dot notation برای پر کردن فیلدهای struct سوم استفاده کنید. هر سه struct را چاپ کنید.

جمع‌بندی

شما چیزهای زیادی درباره انواع composite در Go یاد گرفته‌اید. علاوه بر یادگیری بیشتر درباره رشته‌ها، اکنون می‌دانید که چگونه از انواع کانتینر generic داخلی استفاده کنید: slices و maps. همچنین می‌توانید انواع composite خودتان را از طریق structs بسازید. در فصل بعد، نگاهی به ساختارهای کنترل Go خواهید انداخت: for، if/else و switch. همچنین یاد خواهید گرفت که Go چگونه کد را در قالب blocks سازماندهی می‌کند و چگونه سطوح مختلف block می‌توانند منجر به رفتار غافلگیرکننده شوند.