۱۰ اشتباه رایج در پروژه های Go – راهنمای کاربردی
در این مقاله به بررسی فهرستی از 10 اشتباه رایج در پروژه های Go پرداختهایم. توجه داشته باشید که ترتیب بیان اشتباهات لزوماً نشاندهنده اهمیت آنها نیست.
مقدار Enum ناشناس
به مثال ساده زیر توجه کنید:
1type Status uint32
2
3const (
4 StatusOpen Status = iota
5 StatusClosed
6 StatusUnknown
7)
در این مثال ما یک enum با استفاده از iota ساختیم که حالت زیر را ایجاد میکند:
StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2
اکنون تصور کنید این نوع Status بخشی از یک درخواست JSON باشد و قرار است marshalled/unmarshalled شود. میتوان ساختار زیر را طراحی کرد:
1type Request struct {
2 ID int `json:"Id"`
3 Timestamp int `json:"Timestamp"`
4 Status Status `json:"Status"`
5}
سپس درخواستهایی مانند زیر میگیریم:
1{
2 "Id": 1234,
3 "Timestamp": 1563362390,
4 "Status": 0
5}
در کد فوق هیچ چیز خاصی وجود ندارد، وضعیت به صورت unmarshalled و StatusOpen است. اینک درخواست دیگری در نظر میگیریم که به هر دلیلی مقدار حالت تعیین نشده است:
1{
2 "Id": 1235,
3 "Timestamp": 1563362390
4}
در این حالت، فیلد Status مربوط به ساختار Request مقدار اولیهای برابر با مقدار صفر میگیرند. از این رو به جای StatusUnknown حالت StatusOpen دارد. بهترین رویه این است که مقدار ناشناس را به صورت یک enum با مقدار صفر تعیین کنیم:
1type Status uint32
2
3const (
4 StatusUnknown Status = iota
5 StatusOpen
6 StatusClosed
7)
در این صورت اگر وضعیت، بخشی از درخواست JSON نباشد، مقدار اولیه آن برابر انتظار ما به صورت StatusUnknown خواهد بود.
بنچمارک کردن
بنچمارک کردن صحیح کار دشواری است. عوامل زیادی وجود دارند که میتوانند بر نتیجه مفروض تأثیر بگذارند. یکی از رایجترین اشتباهات این است که گول برخی بهینهسازیهای کامپایلر را بخوریم. مثال دقیق زیر را از کتابخانه teivah/bitvector در نظر بگیرید:
1func clear(n uint64, i, j uint8) uint64 {
2 return (math.MaxUint64<<j | ((1 << i) - 1)) & n
3}
این تابع بیتهای درون یک بازه مفروض را پاک میکند. برای تهیه بنچمارک از آن میتوان به صورت زیر عمل کرد:
1func BenchmarkWrong(b *testing.B) {
2 for i := 0; i < b.N; i++ {
3 clear(1221892080809121, 10, 63)
4 }
5}
در این بنچمارک، کامپایلر متوجه خواهد شد که clear یک «تابع برگ» (leaf function) است، یعنی هیچ تابع دیگری را فراخوانی نمیکند و از این رو آن را inline خواهد کرد. زمانی که inline شد، متوجه خواهد شد که برخی عوارض جانبی دارد. بنابراین فراخوانی clear به سادگی پاک میشود و نتایج نادقیقی تولید میکند.
یک گزینه میتواند این باشد که نتیجه را به یک متغیر سراسری به صورت زیر تعیین کنیم:
1var result uint64
2
3func BenchmarkCorrect(b *testing.B) {
4 var r uint64
5 for i := 0; i < b.N; i++ {
6 r = clear(1221892080809121, 10, 63)
7 }
8 result = r
9}
در کد فوق کامپایلر، نمیداند که فراخوانی عارضه جانبی تولید میکند یا نه. از این رو بنچمارک کردن درست خواهد بود.
کاربرد گسترده اشارهگرها
ارسال یک متغیر با مقدار، موجب ایجاد یک کپی از آن متغیر میشود. در حالی که ارسال آن با اشارهگر صرفاً آدرس حافظه را کپی میکند. از این رو ارسال با اشارهگر همواره سریعتر است.
اگر شما نیز بر این باور هستید، نگاهی به مثال زیر بیندازید:
1package main
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8type foo struct {
9 ID string `json:"_id"`
10 Index int `json:"index"`
11 GUID string `json:"guid"`
12 IsActive bool `json:"isActive"`
13 Balance string `json:"balance"`
14 Picture string `json:"picture"`
15 Age int `json:"age"`
16 EyeColor string `json:"eyeColor"`
17 Name string `json:"name"`
18 Gender string `json:"gender"`
19 Company string `json:"company"`
20 Email string `json:"email"`
21 Phone string `json:"phone"`
22 Address string `json:"address"`
23 About string `json:"about"`
24 Registered string `json:"registered"`
25 Latitude float64 `json:"latitude"`
26 Longitude float64 `json:"longitude"`
27 Greeting string `json:"greeting"`
28 FavoriteFruit string `json:"favoriteFruit"`
29}
30
31type bar struct {
32 ID string
33 Index int
34 GUID string
35 IsActive bool
36 Balance string
37 Picture string
38 Age int
39 EyeColor string
40 Name string
41 Gender string
42 Company string
43 Email string
44 Phone string
45 Address string
46 About string
47 Registered string
48 Latitude float64
49 Longitude float64
50 Greeting string
51 FavoriteFruit string
52}
53
54var input foo
55
56func init() {
57 err := json.Unmarshal([]byte(`{
58 "_id": "5d2f4fcf76c35513af00d47e",
59 "index": 1,
60 "guid": "ed687a14-590b-4d81-b0cb-ddaa857874ee",
61 "isActive": true,
62 "balance": "$3,837.19",
63 "picture": "http://placehold.it/32x32",
64 "age": 28,
65 "eyeColor": "green",
66 "name": "Rochelle Espinoza",
67 "gender": "female",
68 "company": "PARLEYNET",
69 "email": "rochelleespinoza@parleynet.com",
70 "phone": "+1 (969) 445-3766",
71 "address": "956 Little Street, Jugtown, District Of Columbia, 6396",
72 "about": "Excepteur exercitation labore ut cupidatat laboris mollit ad qui minim aliquip nostrud anim adipisicing est. Nisi sunt duis occaecat aliquip est irure Lorem irure nulla tempor sit sunt. Eiusmod laboris ex est velit minim ut cillum sunt laborum labore ad sunt.\r\n",
73 "registered": "2016-03-20T12:07:25 -00:00",
74 "latitude": 61.471517,
75 "longitude": 54.01596,
76 "greeting": "Hello, Rochelle Espinoza!You have 9 unread messages.",
77 "favoriteFruit": "banana"
78 }`), &input)
79 if err != nil {
80 panic(err)
81 }
82}
83
84func byPointer(in *foo) *bar {
85 return &bar{
86 ID: in.ID,
87 Address: in.Address,
88 Email: in.Email,
89 Index: in.Index,
90 Name: in.Name,
91 About: in.About,
92 Age: in.Age,
93 Balance: in.Balance,
94 Company: in.Company,
95 EyeColor: in.EyeColor,
96 FavoriteFruit: in.FavoriteFruit,
97 Gender: in.Gender,
98 Greeting: in.Greeting,
99 GUID: in.GUID,
100 IsActive: in.IsActive,
101 Latitude: in.Latitude,
102 Longitude: in.Longitude,
103 Phone: in.Phone,
104 Picture: in.Picture,
105 Registered: in.Registered,
106 }
107}
108
109func byValue(in foo) bar {
110 return bar{
111 ID: in.ID,
112 Address: in.Address,
113 Email: in.Email,
114 Index: in.Index,
115 Name: in.Name,
116 About: in.About,
117 Age: in.Age,
118 Balance: in.Balance,
119 Company: in.Company,
120 EyeColor: in.EyeColor,
121 FavoriteFruit: in.FavoriteFruit,
122 Gender: in.Gender,
123 Greeting: in.Greeting,
124 GUID: in.GUID,
125 IsActive: in.IsActive,
126 Latitude: in.Latitude,
127 Longitude: in.Longitude,
128 Phone: in.Phone,
129 Picture: in.Picture,
130 Registered: in.Registered,
131 }
132}
133
134var pointerResult *bar
135
136func BenchmarkByPointer(b *testing.B) {
137 var r *bar
138 b.ResetTimer()
139 for i := 0; i < b.N; i++ {
140 r = byPointer(&input)
141 }
142 pointerResult = r
143}
144
145var valueResult bar
146
147func BenchmarkByValue(b *testing.B) {
148 var r bar
149 b.ResetTimer()
150 for i := 0; i < b.N; i++ {
151 r = byValue(input)
152 }
153 valueResult = r
154}
کد فوق یک بنچمارک روی یک ساختار داده 0.3 کیلوبایتی است که ابتدا به وسیله اشارهگر و سپس با مقدار ارسال و دریافت میشود. 0.3 کیلوبایت مقدار زیادی نیست و از نوع ساختار دادههایی که به طور روزمره میبینیم نیز فاصله چندانی ندارد.
زمانی که این بنچمارک را روی محیط محلی اجرا کنید، میبینید که ارسال با مقدار، بیش از 4 بار سریعتر از ارسال با اشارهگر است. این موضوع تا حدودی خلاف درک شهودی ما است.
توضیح این نتیجه به شیوه مدیریت حافظه در Go ارتباط دارد. توضیح کامل آن خارج از حوصله این مقاله است، اما تلاش میکنیم به صورت فشرده به دلیل این مسئله اشاره کنیم.
یک متغیر میتواند روی هیپ یا پشته تخصیص یابد. بنابراین به صورت خلاصه:
- پشته شامل متغیرهای مداوم برای یک goroutine مفروض است. زمانی که تابع بازگشت مییابد، متغیرها از پشته برداشته (pop) میشوند.
- هیپ شامل متغیرهای مشترک یعنی متغیرهای سراسری و امثال آن است.
مثال ساده زیر را که یک مقدار بازگشت میدهد، در نظر بگیرید:
1func getFooValue() foo {
2 var result foo
3 // Do something
4 return result
5}
در کد فوق، متغیر result از سوی goroutine جاری ایجاد شده است. این متغیر به پشته جاری push میشود. زمانی که تابع بازگشت یابد، کلاینت یک کپی از این متغیر دریافت خواهد کرد. متغیر خودش از پشته pop میشود. این متغیر همچنان در حافظه وجود دارد تا این که از سوی متغیر دیگری پاک شود، اما دیگر نمیتوان به آن دسترسی یافت.
اینک همان مثال را با اشارهگر در نظر بگیرید:
1func getFooPointer() *foo {
2 var result foo
3 // Do something
4 return &result
5}
متغیر result همچنان از سوی goroutine جاری ایجاد شده است، اما کلاینت یک اشارهگر یعنی یک آدرس متغیر دریافت خواهد کرد. اگر متغیر result از پشته برداشته شود، کلاینتهای تابع دیگر نمیتواند به آن دسترسی پیدا کند.
در این سناریو کامپایلر Go متغیر result را به مکانی منتقل میکند که بتواند به اشتراک گذارده شود و اینجا مکانی نیست به جز هیپ.
با این حال ارسال اشارهگر نیز یک سناریوی دیگر است. برای مثال:
1func main() {
2 p := &foo{}
3 f(p)
4}
از آنجا که f را درون همان goroutine فراخوانی میکنیم، متغیر p نیازی به انتقال ندارد. این متغیر به سادگی به پشته پوش میشود و تابع فرعی میتواند به آن دسترسی داشته باشد.
برای نمونه این یک نتیجه مستقیم دریافت یک slice در متد Read مربوط به io.Reader به جای بازگشت دادن یکی از آنها است. بازگشت دادن یک io.Reader (که یک اشارهگر است) باعث میشود به هیپ انتقال پیدا کند.
اینک سؤال این است که چرا پشته چنین سریع است؟ دو دلیل عمده برای آن وجود دارد:
- نیازی به garbage collector برای پشته وجود ندارد. چنان که گفتیم متغیر یک بار در زمان ایجاد شدن به پشته ارسال میشود و سپس یک بار در زمان بازگشت تابع از آن برداشته میشود. بنابراین هیچ نیازی به فرایند پیچیده تملک مجدد متغیرهای استفاده نشده و کارهایی از این دست وجود ندارد.
- هر پشته به یک goroutine تعلق دارد و از این رو لازم نیست متغیر همانند روش ذخیرهسازی در هیپ همگامسازی شود. این وضعیت موجب بهبود عملکرد نیز میشود.
در نتیجه زمانی که یک تابع ایجاد میکنیم، رفتار پیشفرض ما باید استفاده از مقدار به جای اشارهگر باشد. یک اشارهگر تنها باید در مواردی استفاده شود که میخواهیم متغیری را به اشتراک بگذاریم.
در صورتی که از مشکلات عملکردی رنج میبرید، یک راهحل بهینهسازی ممکن، میتواند این باشد که بررسی کنید آیا اشارهگر در برخی سناریوهای خاص مفید واقع میشود یا نه. با استفاده از دستور زیر میتوان فهمید که آیا یک متغیر به هیپ انتقال مییابد یا نه:
1go build -gcflags "-m -m"
در نهایت یک بار دیگر یادآوری میکنیم که برای اغلب ما در کاربردهای روزمره، استفاده از ارسال با مقدار، بهترین گزینه محسوب میشود.
Break کردن یک for/switch یا یک for/select
در مثال زیر وقتی f مقدار true بازگشت دهد چه رخ میدهد؟
1for {
2 switch f() {
3 case true:
4 break
5 case false:
6 // Do something
7 }
8}
ما در این حالت گزاره break را فراخوانی میکنیم. اما بدین ترتیب گزاره switch به صورت break درخواهد آمد و نه حلقه for. همین مشکل در مورد مثال زیر نیز وجود دارد:
1for {
2 select {
3 case <-ch:
4 // Do something
5 case <-ctx.Done():
6 break
7 }
8}
چنان که میبینید Break به گزاره select مربوط است و نه حلقه for. یک راهحل ممکن برای break کردن گزاره for/switch یا یک گزاره for/select استفاده از labeled break مانند زیر است:
1for {
2 select {
3 case <-ch:
4 // Do something
5 case <-ctx.Done():
6 break
7 }
8}
مدیریت خطا
Go در مسیر مدیریت خطای خود همچنان تا حدودی ناپخته به نظرمی رسد. دقیقاً به همین دلیل است که یکی از بزرگترین انتظارات ما از نسخه 2 زبان Go حل این مشکل است.
کتابخانه استاندارد کنونی (پیش از Go 1.13) تنها تابعهایی برای ساخت خطاها ارائه میکرد، بنابراین شاید بهتر باشد نگاهی به pkg/errors (+) داشته باشید.
این کتابخانه روش مناسبی برای رعایت این قاعده ساده است که البته در اغلب موارد نادیده گرفته میشود:
یک خطا باید تنها یکبار مدیریت شود. لاگ کردن یک خطا به معنی مدیریت خطا است. بنابراین یک خطا یا باید لاگ شود و یا انتشار یابد.
با استفاده از کتابخانه استاندارد کنونی رعایت این قاعده دشوار است، زیرا ممکن است بخواهیم نوعی چارچوب به یک خطا بیفزاییم و شکلی از سلسلهمراتب داشته باشیم.
در ادامه مثالی در همین رابطه مشاهده میکنید. در این مثال یک فراخوانی REST منجر به ایجاد مشکل در پایگاه داده میشود:
unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction
اگر از کتابخانه pkg/errors استفاده کنیم، میتوانیم به صورت زیر عمل کنیم:
1func postHandler(customer Customer) Status {
2 err := insert(customer.Contract)
3 if err != nil {
4 log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
5 return Status{ok: false}
6 }
7 return Status{ok: true}
8}
9
10func insert(contract Contract) error {
11 err := dbQuery(contract)
12 if err != nil {
13 return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
14 }
15 return nil
16}
17
18func dbQuery(contract Contract) error {
19 // Do something then fail
20 return errors.New("unable to commit transaction")
21}
خطای اولیه (اگر از سوی یک کتابخانه اکسترنال بازگشت نیافته باشد) میتواند با استفاده از errors.New ایجاد شده باشد. لایه میانی insert این خطا را به وسیله افزودن چارچوب بیشتر به آن پوشش میدهد. سپس والد با لاگ کردن خطا آن را مدیریت میکند. بدن ترتیب در هر سطح یا مقداری بازگشت مییابد و یا خطا مدیریت میشود.
همچنین میتوانیم منشأ خطا را نیز بررسی کنیم تا بدانیم آیا به یک تلاش مجدد نیاز داریم یا نه. فرض کنید یک پکیج db از یک کتابخانه بیرونی با دسترسیهای پایگاه داده سر و کار دارد. این کتابخانه میتواند یک خطای گذرا (موقت) به نام db.DBError ایجاد کند. برای تعیین این که آیا به تلاش مجدد نیاز داریم یا نه، باید منشأ خطا را بررسی کنیم:
1func postHandler(customer Customer) Status {
2 err := insert(customer.Contract)
3 if err != nil {
4 switch errors.Cause(err).(type) {
5 default:
6 log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
7 return Status{ok: false}
8 case *db.DBError:
9 return retry(customer)
10 }
11
12 }
13 return Status{ok: true}
14}
15
16func insert(contract Contract) error {
17 err := db.dbQuery(contract)
18 if err != nil {
19 return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
20 }
21 return nil
22}
این کار با استفاده از errors.Cause انجام مییابد که آن نیز از پکیج pkg/errors میآید:
یک اشتباه رایج، استفاده ناقص از پکیج pkg/errors است. برای نمونه بررسی خطا به صورت زیر اجرا میشود:
1switch err.(type) {
2default:
3 log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
4 return Status{ok: false}
5case *db.DBError:
6 return retry(customer)
7}
در این مثال اگر db.DBError پوشش پیدا کند، هرگز موجب تلاش مجدد نمیشود.
مقداردهی اولیه Slice
برخی اوقات ما میدانیم که طول نهایی یک Slice چه قدر خواهد بود. برای نمونه تصور کنید باید یک Slice به نام Foo را به یک Slice به نام Bar تبدیل کنیم، یعنی دو Slice طولی یکسان دارند. ما غالباً میبینیم که Slice-ها به صورت زیر مقداردهی اولیه میشوند:
1var bars []Bar
2bars := make([]Bar, 0)
توجه کنید که Slice ساختاری جادویی محسوب نمیشود. این ساختار در صورت نبود فضای بیشتر، در پسزمینه یک راهبرد رشد را پیادهسازی میکند. در این حالت یک آرایه جدید به صورت خودکار ایجاد میشود که ظرفیت بالاتری دارد و همه آیتمها روی آن کپی میشوند.
اینک تصور کنید نیاز داریم این عملیات رشد را چند بار تکرار کنیم، زیرا []Foo ما شامل هزاران آیتم است. پیچیدگی زمانی عملیات درج همچنان (O(1 خواهد بود، اما در عمل با مشکل عملکردی مواجه میشویم.
از این رو اگر طول نهایی را بدانیم میتوانیم به یکی از روشهای زیر عمل کنیم:
مقداردهی با طول از قبل تعریفشده:
1func convert(foos []Foo) []Bar {
2 bars := make([]Bar, len(foos))
3 for i, foo := range foos {
4 bars[i] = fooToBar(foo)
5 }
6 return bars
7}
یا مقداردهی با طول 0 و ظرفیت از پیش تعریفشده:
1func convert(foos []Foo) []Bar {
2 bars := make([]Bar, 0, len(foos))
3 for _, foo := range foos {
4 bars = append(bars, fooToBar(foo))
5 }
6 return bars
7}
بهترین گزینه کدام است؟ گزینه اول کمی سریعتر است. با این حال ممکن است گزینه دوم را بیشتر ترجیح بدهید زیرا موجب میشود همه چیز منسجمتر بماند. صرفنظر از این که اندازه اولیه را میدانیم یا نه، افزودن یک عنصر به انتهای یک slice با استفاده از append انجام مییابد.
مدیریت چارچوب (Context)
context.Contex غالباً از سوی توسعهدهندگان به اشتباه فهمیده میشود. بر اساس مستندات رسمی:
یک چارچوب حامل یک ضربالاجل، یک سیگنال لغو و دیگر مقادیر روی API است.
این توصیف کلی است، اما باعث میشود که برخی افراد در مورد چرایی و چگونگی استفاده از آن دچار سردرگمی شوند.
در ادامه آن را بیشتر باز میکنیم. یک چارچوب میتواند شامل موارد زیر باشد:
- یک ضربالاجل یا deadline: این ضربالاجل یا شامل یک مدت زمان (برای مثال 250 میلیثانیه) یا یک تاریخ-زمان (مثلاً 2019-01-08 01:00:00) است که در زمان فرا رسیدن این زمان، باید فعالیت جاری یعنی درخواست I/O، انتظار برای ورودی کانال و غیره را لغو کنیم.
- سیگنال لغو (Cancelation Signal) نیز رفتار مشابهی دارد. زمانی که یک سیگنال دریافت شود باید فعالیت جاری متوقف شود. برای نمونه تصور کنید که ما مشغول دریافت دو درخواست هستیم. فرض کنید یکی برای درج برخی دادهها و دیگری برای لغو درخواست اول (چون دیگر موضوعیت ندارد یا هر دلیل دیگر) است. این وضعیت از طریق استفاده از چارچوب قالب لغو در فراخوانی اول میسر است تا به محض دریافت درخواست دوم بتوانیم اولی را لغو کنیم.
- یک لیست از کلید/مقدار که هر دو از نوع {}interface هستند.
دو نکته وجود دارد که باید اضافه کنیم. ابتدا این که یک چارچوب composable است. بنابراین میتوانیم یک چارچوب داشته باشیم که شامل یک ضربالاجل باشد و یک لیست از کلید/مقدار نیز برای نمونه داشته باشد. به علاوه goroutine-های چندگانه میتوانند چارچوب واحدی را به اشتراک بگذارند، بدین ترتیب سیگنال لغو میتواند به صورت بالقوه چند فعالیت را متوقف کند.
اگر به موضوع اصلی خود بازگردیم، در اینجا یک اشتباه مهم دیده میشود.
یک اپلیکیشن Go بر مبنای urfave/cli ساخته شده است. این کتابخانهای زیبا است که برای ایجاد اپلیکیشنهای خط فرمان در Go استفاده میشود. زمانی که کار توسعه اپلیکیشن آغاز میشود، توسعهدهنده از نوعی چارچوب اپلیکیشن ارثبری میکند. این بدان معنی است که وقتی اپلیکیشن متوقف شود، کتابخانه از این چارچوب برای ارسال سیگنال لغو استفاده میکند.
در چنین حالتی دقیقاً همین چارچوب مستقیماً در زمان فراخوانی نقطه انتهایی gRPC ارسال میشود. ولی این آن چیزی نیست که ما میخواهیم. به جای آن ما میخواهیم به کتابخانه gRPC اعلام کنیم که درخواست را زمانی که اپلیکیشن متوقف شده است و یا پس از مثلاً 100 میلیثانیه لغو کند.
برای نیل به این مقصود میتوانیم یک «چارچوب ترکیبی» (composed context) ایجاد کنیم. اگر parent نام چارچوب اپلیکیشن باشد، در این حالت به صورت زیر عمل میکنیم:
1ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
2response, err := grpcClient.Send(ctx, request)
درک چارچوبها کار چندان پیچیدهای نیست و یکی از بهترین ویژگیهای زبان برنامهنویسی Go محسوب میشوند.
عدم استفاده از گزینه race-
خطایی که غالباً در زمان تست کردن اپلیکیشنهای Go مشاهده میشود، عدم استفاده از گزینه race- است. با این که Go طوری طراحی شده است که برنامهنویسی concurrent راحتتر و مستعد خطای کمتری است، اما همچنان از مشکلات همزمانی زیادی رنج میبرد.
بدیهی است که تشخیصدهنده race در Go برای همه مشکلات همزمانی چارهساز نیست. در هر حال این ابزار ارزشمندی است و باید همواره در زمان تست اپلیکیشنها مورد استفاده قرار گیرد.
استفاده از نام فایل به عنوان ورودی
اشتباه رایج دیگر در زمان برنامهنویسی Go ارسال نام فایل به یک تابع است. فرض کنید ما میخواهیم تابعی را برای شمارش خطوط خالی یک فایل پیادهسازی کنیم. طبیعیترین پیادهسازی چیزی مانند زیر خواهد بود:
1func count(filename string) (int, error) {
2 file, err := os.Open(filename)
3 if err != nil {
4 return 0, errors.Wrapf(err, "unable to open %s", filename)
5 }
6 defer file.Close()
7
8 scanner := bufio.NewScanner(file)
9 count := 0
10 for scanner.Scan() {
11 if scanner.Text() == "" {
12 count++
13 }
14 }
15 return count, nil
16}
Filename به عنوان یک ورودی عرضه میشود و از این رو آن را باز میکنیم و سپس منطق خود را پیادهسازی میکنیم.
اینک فرض کنید میخواهیم تستهای unit را روی این تابع اجرا کنیم تا با یک فایل نرمال، یک فایل خالی، یک فایل با نوع انکودینگ متفاوت و غیره آن را تست کنیم. چنان که حدس میزنید مدیریت این کار به سرعت دشوار خواهد شد.
ضمناً اگر بخواهیم همین منطق را پیادهسازی کنیم، اما از بدنه HTTP استفاده کنیم، باید تابع دیگری برای آن ایجاد کنیم.
Go دو لایه تجرید عالی به این منظور دارد که یکی io.Reader و دیگری io.Writer است. به جای ارسال یک نام فایل میتوانیم به سادگی یک io.Reader ارسال کنیم و منبع داده را تجرید نماییم.
شاید بپرسید io.Reader یک فایل است؟ یک بدنه HTTP یک بافر بایت و یا شاید چیز دیگری است؟ این مسئله اهمیتی ندارد زیرا ما همچنان همان متد Read را داریم.
در این مورد میتوانیم حتی ورودی را بافر کنیم تا آن را خط به خط بخوانیم. بنابراین میتوانیم از bufio.Reader و از متد ReadLine آن استفاده کنیم:
1func count(reader *bufio.Reader) (int, error) {
2 count := 0
3 for {
4 line, _, err := reader.ReadLine()
5 if err != nil {
6 switch err {
7 default:
8 return 0, errors.Wrapf(err, "unable to read")
9 case io.EOF:
10 return count, nil
11 }
12 }
13 if len(line) == 0 {
14 count++
15 }
16 }
17}
مسئولیت باز کردن خود فایل اکنون به کلاینت count واگذار شده است:
1file, err := os.Open(filename)
2if err != nil {
3 return errors.Wrapf(err, "unable to open %s", filename)
4}
5defer file.Close()
6count, err := count(bufio.NewReader(file))
در پیادهسازی دوم تابع میتواند صرفنظر از این که منبع داده واقعی چیست فراخوانی شود. در عین حال، تستهای واحد تسهیل میشوند، زیرا میتوان bufio.Reader از یک string ساخت:
1count, err:= count(bufio.NewReader(strings.NewReader("input")))
Goroutine-ها و متغیرهای حلقه
آخرین اشتباه رایجی که در این مقاله بررسی میکنیم به استفاده از متغیرهای حلقه در Goroutine ها مربوط است. خروجی مثال زیر چیست؟
1ints := []int{1, 2, 3}
2for _, i := range ints {
3 go func() {
4 fmt.Printf("%v\n", i)
5 }()
6}
اگر فکر میکنید خروجی مثال فوق 1 2 3 با هر نوع ترتیبی است اشتباه میکنید. در این مثال هر goroutine وهله یکسانی از متغیر را به اشتراک میگذارد و از این رو خروجی آن 3 3 3 است.
دو راهحل برای این مشکل وجود دارد. راهحل نخست این است که مقدار متغیر i را به بستار (تابع درونی) ارسال کنیم:
1ints := []int{1, 2, 3}
2for _, i := range ints {
3 go func(i int) {
4 fmt.Printf("%v\n", i)
5 }(i)
6}
و راهحل دوم این است که متغیر دیگری درون دامنه حلقه for ایجاد کنیم:
1ints := []int{1, 2, 3}
2for _, i := range ints {
3 i := i
4 go func() {
5 fmt.Printf("%v\n", i)
6 }()
7}
با این که فراخوانی i:= i ممکن است کمی عجیب به نظر برسد، اما کاملاً معتبر است. درون یک حلقه بودن یعنی درون یک دامنه دیگر قرار داریم. بنابراین i:= i یک وهله متغیر دیگری به نام i میسازد. البته میتوانیم آن را به نام دیگری نامگذاری کنیم تا خوانایی کد افزایش یابد.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- آموزش توسعه وب با زبان برنامه نویسی Go
- مجموعه آموزشهای دروس علوم و مهندسی کامپیوتر
- زبان برنامه نویسی Go — راهنمای شروع به کار
- آموزش زبان برنامه نویسی Go: ساخت یک سرور چت به زبان ساده
==