9.7.1 اصول SOLID

9.7.1 اصول SOLID

9.7.1 مقدمه #

این مقاله ترجمه ارایه اصول solid در golang توسط Dave Cheneyمی‌‌باشد.

در این مبحث به بررسی پیاده سازی solid در زبان Go می‌پردازیم.

  • بررسی کد

  • چه کسی اینجا بررسی کد را به عنوان بخشی از کار خود انجام می‌دهد؟ تمام اتاق دستشان را بالا بردند که تشویق کننده بود. خوب، چرا بررسی کد انجام می‌دهید؟ کسی فریاد زد «برای جلوگیری از کد بد»

  • اگر code review برای شناسایی کد‌های نامناسب است، پس چگونه می‌دانید کدی که بررسی می‌کنید خوب است یا بد؟

  • حالا خوب است بگوییم «آن کد نامناسب است» یا «آن کد منبع زیبا است»، درست مانند اینکه بگویید «این نقاشی زیبا است» یا «این اتاق زیبا است» اما اینها اصطلاحات ذهنی هستند و من به دنبال راه‌های عینی برای صحبت در مورد خواص کد خوب یا بد هستم.

کد بد #

  • برخی از ویژگی‌های کد بد که ممکن است در بررسی کد به آن پی ببرید کدامند؟

  • سفت و خشک یا در اصطلاح Rigid. آیا کد Rigid است؟ آیا دارای یک محافظ محدود کننده از انواع و پارامترهای غالب است که اصلاح آن را دشوار می‌کند؟

  • شکننده یا در اصطلاح Fragile. آیا کد Fragile است؟ آیا کوچکترین تغییر در کد باعث ایجاد ویرانی‌های فراوان می‌شود؟

  • بی‌حرکتی یا Immobile . آیا تغییر در ساختار کد سخت است؟ آیا اضافه کردن یک حلقه ساده در برنامه کار پیچیده‌ای است؟

  • پیچیده (Complex). آیا کدها به دلیل اینکه فقط کدی نوشته شده باشد بدون آن که به آن نیاز باشد، وجود دارد، آیا چیزها بیش از حد مهندسی (over-engineered) شده‌اند؟

  • شرح دادن بیش از حد یا Verbose. آیا استفاده و بررسی از کد خسته‌کننده است؟ وقتی به آن نگاه می‌کنید، می‌توانید بگویید این کد چه کاری می‌خواهد انجام دهد؟

  • آیا این کلمات مثبت هستند؟ آیا از شنیدن این کلمات در بررسی کد خود خوشحال خواهید شد؟

  • احتمالا نه.

طراحی خوب #

اما این یک بهبود دادن در سطح کد است، حالا می‌توانیم چیزهایی مثل «من این را دوست ندارم چون خیلی سخت است تغییرش داد» یا «من این را دوست ندارم چون نمی‌توانم بفهمم کد چه کاری می‌خواهد انجام دهد» بگوییم، اما چه می‌شود اگر با مثبت شروع کنیم؟

آیا خوب نمی‌شد اگر راه‌هایی برای توصیف ویژگی‌های طراحی خوب وجود داشت، نه فقط طراحی بد و بتوانیم این کار را با اصطلاحات عینی انجام دهیم؟

بررسی SOLID #

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

  • Single Responsibility Principle
  • Open / Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

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

بنابراین می‌خواهم مدتی را صرف بحث در مورد این موضوع با شما کنم.

9.7.2 اصل Single Responsibility #

اولین اصل S در SOLID که اصل مسئولیت واحد است.

یک کلاس باید یک و تنها یک دلیل برای تغییر داشته باشد.

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

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

بنابراین کدی که مسئولیت واحدی دارد، در نتیجه کمترین دلایل برای تغییر را دارد.

9.7.2.1 بررسی Coupling و Cohesion #

کوپلینگ و Cohesion دو کلمه‌ای که توصیف می‌کنند تغییر یک نرم‌افزار چقدر آسان یا سخت است، Coupling و Cohesion هستند.

کوپلینگ به سادگی کلمه‌ای است که دو چیز را توصیف می‌کند که با هم تغییر می‌کنند - حرکت در یکی باعث حرکت در دیگری می‌شود.

یک مفهوم مرتبط اما جداگانه، ایده Cohesion است، نیروی جذب متقابل.

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

برای توصیف واحدهای کوپلینگ و Cohesion در یک برنامه Go، ممکن است در مورد توابع و متدها صحبت کنیم، همانطور که در هنگام بحث در مورد SRP بسیار رایج است، اما من معتقدم که این کار با مدل packageهای Go شروع می‌شود.

9.7.2.2 نام Package‌ها #

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

  • ‏net/http که کلاینت‌ها و سرورهای http را فراهم می‌کند.
  • ‏os/exec که دستورات خارجی را اجرا می‌کند.
  • ‏encoding/json،که کدگذاری و رمزگشایی اسناد JSON را پیاده‌سازی می‌کند.

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

9.7.2.3 نام‌های بد Package #

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

بسته‌ی server چه چیزی را فراهم می‌کند؟ … خب، امیدوارم یک سرور، اما با کدام پروتکل؟

بسته‌ی private چه چیزی را فراهم می‌کند؟ چیزهایی که نباید ببینم؟ آیا باید نمادهای public داشته باشد؟

و بسته‌ی common، درست مثل همکارش، بسته‌ی utils، اغلب در نزدیکی این متخلفان دیگر پیدا می‌شود.

بسته‌های همه کاره‌ی مانند این به یک محل دفن زباله برای چیزهای مختلف تبدیل می‌شوند و چون مسئولیت‌های زیادی دارند، اغلب بدون دلیل تغییر می‌کنند.

9.7.2.4 فلسفه‌ی یونیکس در Go #

به نظر من، هیچ بحثی در مورد طراحی جدا شده بدون ذکر فلسفه‌ی یونیکس  Doug McIlroy کامل نمی‌شود؛ ابزارهای کوچک و چابک که برای حل کارهای بزرگ‌تر ترکیب می‌شوند، اغلب کارهایی که توسط نویسندگان اصلی پیش‌بینی نشده بود.

فکر می‌کنم بسته‌های Go روحیه‌ی فلسفه‌ی یونیکس را تجسم می‌بخشند. در واقع هر بسته‌ی Go خود یک برنامه‌ی کوچک Go است، یک اثرگذاری کوچک با یک مسئولیت واحد.

9.7.2.5 مثال Single Responsibility #

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

فرض کنید من یک کارمند ساختاری دارم که نام، حقوق و آدرس یک کارمند را پیگیری می‌کند:

1type Employee struct {
2	Name string 
3	Salary float64 
4	Address string 
5}

طبق SRP، هر ساختار باید تنها یک مسئولیت داشته باشد، بنابراین در این مورد، بهتر است مسئولیت‌های ساختار Employee به دو ساختار جداگانه تقسیم شود: EmployeeInfo و EmployeeAddress.

1type EmployeeInfo struct {
2    Name   string
3    Salary float64
4}
5
6type EmployeeAddress struct {
7    Address string
8}

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

1func (e EmployeeInfo) GetSalary() float64 {
2    return e.Salary
3}
4
5func (e EmployeeAddress) GetAddress() string {
6    return e.Address
7}

با پیروی از SRP، من کد را قابل نگهداری تر و درک آن آسان‌تر کرده‌ام، زیرا اکنون هر ساختار مسئولیت مشخص و مشخصی دارد. اگر بخواهم تغییراتی در محاسبه حقوق و دستمزد یا رسیدگی به آدرس ایجاد کنم، دقیقاً می‌دانم به کجا نگاه کنم، بدون اینکه نیازی به کدهای نامرتبط زیادی داشته باشم.

9.7.3 اصل Open / Closed #

اصل دوم، O، اصل بسته باز توسط برتراند مایر است که در سال 1988 نوشت:

موجودیت‌های نرم‌افزار باید برای توسعه باز باشند، اما برای اصلاح بسته شوند.
– برتراند مایر، ساخت نرم افزار شی گرا

این توصیه چگونه در مورد زبانی که 21 سال بعد نوشته شده است صدق می کند؟

 1package main
 2
 3type A struct {
 4        year int
 5}
 6
 7func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }
 8
 9type B struct {
10        A
11}
12
13func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }
14
15func main() {
16        var a A
17        a.year = 2016
18        var b B
19        b.year = 2016
20        a.Greet() // Hello GolangUK 2016
21        b.Greet() // Welcome to GolangUK 2016
22}

ما یک type به نام A داریم با یک field به نام year و یک متد به نام Greet. یک type دوم به نام B داریم که A را در خود جای می‌دهد، بنابراین فراخواننده‌ها متدهای B را روی متدهای A می‌بینند زیرا A به عنوان یک field در داخل B جاسازی شده است و B می‌تواند متد Greet خود را ارائه دهد و آن را از A پنهان کند.

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

بنابراین جاسازی (embedding) یک ابزار قدرتمند است که به تایپ‌های Go اجازه می‌دهد برای گسترش باز باشند.

 1package main
 2
 3type Cat struct {
 4        Name string
 5}
 6
 7func (c Cat) Legs() int { return 4 }
 8
 9func (c Cat) PrintLegs() {
10        fmt.Printf("I have %d legs\n", c.Legs())
11}
12
13type OctoCat struct {
14        Cat
15}
16
17func (o OctoCat) Legs() int { return 5 }
18
19func main() {
20        var octo OctoCat
21        fmt.Println(octo.Legs()) // 5
22        octo.PrintLegs()         // I have 4 legs
23}

در این مثال، ما یک type به نام Cat داریم که می‌تواند تعداد پاهای خود را با متد Legs بشمارد. ما این نوع Cat را در یک نوع جدید به نام OctoCat جاسازی می‌کنیم و اعلام می‌کنیم که Octocatها پنج پا دارند. با این حال، اگرچه OctoCat متد Legs خود را تعریف می‌کند که 5 برمی‌گرداند، اما وقتی متد PrintLegs فراخوانی می‌شود، 4 برمی‌گرداند.

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

بنابراین می‌توانیم بگوییم که انواع Go در حالی که برای گسترش باز هستند، برای تغییر بسته هستند.

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

1func (c Cat) PrintLegs() {
2        fmt.Printf("I have %d legs\n", c.Legs())
3}
4
5func PrintLegs(c Cat) {
6        fmt.Printf("I have %d legs\n", c.Legs())
7}

گیرنده یا receiver دقیقا همان چیزی است که به آن پاس می‌دهید، اولین پارامتر function است و از آنجا که Go از  function overloading پشتیبانی نمی‌کند، در نتیجه OctoCatها جایگزین Cats معمولی نمی‌شوند. که من را به اصل بعدی در solid می‌رساند.

9.7.3.1 مثال Open / Closed #

فرض کنید من وظیفه دارم یک سیستم پرداخت بسازم که بتواند پرداخت‌های کارت اعتباری را پردازش کند. همچنین باید به‌اندازه کافی انعطاف‌پذیر باشد تا انواع روش‌های پرداخت را در آینده بپذیرد.

 1package main
 2
 3import "fmt"
 4
 5type PaymentMethod interface {
 6  Pay()
 7}
 8
 9type Payment struct{}
10
11func (p Payment) Process(pm PaymentMethod) {
12  pm.Pay()
13}
14
15type CreditCard struct {
16  amount float64
17}
18
19func (cc CreditCard) Pay() {
20  fmt.Printf("Paid %.2f using CreditCard", cc.amount)
21}
22
23func main() {
24  p := Payment{}
25  cc := CreditCard{12.23}
26  p.Process(cc)
27}

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

 1type PayPal struct {
 2  amount float64
 3}
 4
 5func (pp PayPal) Pay() {
 6  fmt.Printf("Paid %.2f using PayPal", pp.amount)
 7}
 8
 9// then in main()
10pp := PayPal{22.33}
11p.Process(pp)

9.7.4 اصل Liskov Substitution #

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

در یک زبان مبتنی بر کلاس‌ها، اصل جایگزینی لیسکوف معمولاً به عنوان یک مشخصات برای یک abstract base class با زیرگونه‌های concrete class مختلف تفسیر می‌شود. اما Go کلاس یا وراثت ندارد، بنابراین جایگزینی نمی‌تواند از نظر سلسله مراتب abstract class پیاده‌سازی شود.

9.7.4.1 بررسی Interface ها #

در عوض، پایده سازی اصل جایگزینی (substitution) در این حوزه‌ بر عهده Interfaceها در Go است. در Go، از تایپ‌ها انتظار نمی‌رود که یک Interface خاصی را که پیاده‌سازی می‌کنند را از قبل معرفی کنند، در عوض هر تایپ یک Interface را پیاده‌سازی می‌کند به شرطی که متدهایی داشته باشد که امضای (signature) آن با اعلامیه اینترفیس (interface declaration) مطابقت داشته باشد.

ما می‌گوییم که در Go، رابط‌ها یا Interfaceها به طور ضمنی (implicitly) برآورده می‌شوند، نه صریح یا explicitly و این تأثیر عمیقی بر نحوه استفاده از آن‌ها در این زبان برنامه نویسی دارد.

اینترفیس‌های طراحی شده خوب در بیشتر موارد احتمال دارد که اینترفیس‌های کوچکی باشند؛ ضرب‌المثل غالب این است که یک Interface فقط یک متد دارد. منطقی است که اینترفیس‌های کوچک منجر به پیاده‌سازی‌های ساده شوند، زیرا انجام خلاف آن دشوار است. که منجر به بسته‌هایی می‌شود که از پیاده‌سازی‌های ساده تشکیل شده‌اند و توسط رفتار مشترک به هم متصل شده‌اند.

9.7.4.2 بررسی io.Reader #

1type Reader interface {
2        // Read reads up to len(buf) bytes into buf.
3        Read(buf []byte) (n int, err error)
4}

در ادامه مبحث که من را به io.Reader می‌رساند، به راحتی مورد علاقه من در بین اینترفیس‌ها در Go است.

اینترفیس io.Reader بسیار ساده است؛ Read داده‌ها را به بافر تأمین شده می‌خواند و تعداد بایت‌های خوانده شده و هر خطایی که در حین خواندن رخ داده است را به فراخواننده برمی‌گرداند. به نظر ساده می‌آید اما بسیار قدرتمند است.

از آنجایی که io.Reader با هر چیزی که بتوان آن را به عنوان یک stream از بایت‌ها بیان کرد سر و کار دارد، می‌توانیم خواننده‌ها را روی تقریباً هر چیزی ساختیم؛ یک رشته ثابت، یک آرایه بایت، ورودی استاندارد، یک جریان شبکه، یک فایل فشرده gzip، خروجی استاندارد یک فرمان که از طریق ssh به صورت remote اجرا می‌شود.

و تمام این پیاده‌سازی‌ها برای یکدیگر قابل جایگزینی هستند زیرا قرارداد ساده یکسانی را برآورده می‌کنند.

بنابراین اصل جایگزینی لیسکوف، اعمال شده بر روی Go، می‌تواند با این ضرب‌المثل زیبا از Jim Weirich خلاصه شود.

Require no more, promise no less.
–Jim Weirich

و این یک حرکت عالی در چهارمین اصل SOLID است.

9.7.4.3 مثال Liskov Substitution #

بیایید یک struct Animal را در نظر بگیریم:

1type Animal struct {
2  Name string
3}
4
5func (a Animal) MakeSound() {
6  fmt.Println("Animal sound")
7}

حال، فرض کنید می‌خواهیم یک ساختار جدید Bird بسازیم که نشان دهنده نوع خاصی از حیوانات است:

1type Bird struct {
2  Animal
3}
4
5func (b Bird) MakeSound() {
6  fmt.Println("Chirp chirp")
7}

این اصل بیان می‌کند که اشیاء یک superclass باید با اشیاء یک زیر کلاس بدون تأثیر بر صحت برنامه قابل‌تعویض باشند. این کمک می‌کند تا اطمینان حاصل شود که روابط بین کلاس ها به‌خوبی تعریف شده و قابل حفظ است.

 1type AnimalBehavior interface {
 2  MakeSound()
 3}
 4
 5// MakeSound represent a program that works with animals and is expected
 6// to work with base class (Animal) or any subclass (Bird in this case)
 7func MakeSound(ab AnimalBehavior) {
 8  ab.MakeSound()
 9}
10
11a := Animal{}
12b := Bird{}
13MakeSound(a)
14MakeSound(b)

این وراثت در Go و همچنین اصل جایگزینی Liskov را نشان می‌دهد، زیرا اشیاء یک نوع فرعی Bird را می‌توان در هر جایی که اشیایی از نوع پایه Animal انتظار می‌رود استفاده کرد، بدون اینکه بر صحت برنامه تأثیر بگذارد.

9.7.5 اصل Interface Segregation #

چهارمین اصل، اصل جداسازی اینترفیس است که به شرح زیر است:

کلاینت نباید مجبور شوند که به متدهایی که استفاده نمی‌کنند وابسته باشند.
-رابرت سی مارتین

1// Save writes the contents of doc to the file f.
2func Save(f *os.File, doc *Document) error

من می‌توانم این تابع را تعریف کنم، بیایید آن را Save بنامیم، که یک *os.File را به عنوان مقصد برای نوشتن  Document می‌گیرد. اما این کار چند مشکل دارد.

امضای یا signature مخصوص Save گزینه نوشتن داده‌ها به یک مکان شبکه‌ای را از بین می‌برد. با فرض اینکه ذخیره‌سازی شبکه احتمالاً بعداً به یک نیاز تبدیل می‌شود، امضای این تابع باید تغییر کند و روی تمام فراخواننده‌های آن تأثیر بگذارد.

از آنجایی که Save مستقیماً با فایل‌ها روی دیسک کار می‌کند، تست کردن آن ناخوشایند است. برای تأیید عملکرد آن، تست باید محتوای فایل را بعد از نوشتن بخواند. علاوه بر این، تست باید اطمینان حاصل کند که f به یک مکان موقت نوشته شده است و همیشه بعد از آن حذف می‌شود.

همچنین os.File متدهای زیادی را تعریف می‌کند که با Save مرتبط نیستند، مانند خواندن دایرکتوری‌ها و بررسی اینکه آیا یک مسیر یک symlink است. بسیار مفید خواهد بود اگر امضای تابع Save ما بتواند فقط قسمت‌های مرتبط os.File را توصیف کند.

پس با این مشکلات چه کنیم؟

1// Save writes the contents of doc to the supplied Writer.
2func Save(w io.Writer, doc *Document) error

یک راه حل بهتر این است که Save را دوباره تعریف کنیم تا فقط یک io.Writer بگیرد و مسئولیت انجام هر کاری غیر از نوشتن داده‌ها به یک جریان را کاملاً از آن بگیرد.

با اعمال اصل جداسازی interface بر روی تابع Save، نتیجه همزمان یک function است که از نظر نیازهای خود خاص‌ترین است و فقط به چیزی نیاز دارد که قابل نوشتن باشداکنون می‌توانیم از Save برای ذخیره داده‌های خود به هر چیزی که io.Writer را پیاده‌سازی می‌کند، استفاده کنیم.

یک قانون بزرگ برای Go پذیرش interfaceها در structهای بازگشتی است. Jack Lindamood

9.7.5.1 مثال Interface Segregation: #

فرض کنید یک interface برای چاپگر اسناد داریم:

1goCopy codetype Printer interface {
2    Print()
3    Scan()
4    Fax()
5}

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

 1type Printer interface {
 2    Print()
 3}
 4
 5type Scanner interface {
 6    Scan()
 7}
 8
 9type FaxMachine interface {
10    Fax()
11}

9.7.6 اصل Dependency Inversion #

اصل SOLID نهایی، اصل وارونگی وابستگی است که بیان می‌کند:

ماژول های سطح بالا نباید به ماژول های سطح پایین وابسته باشند. هر دو باید به انتزاعات بستگی داشته باشند.
انتزاع ها نباید به جزئیات بستگی داشته باشند. جزئیات باید به انتزاعات بستگی داشته باشد.
-رابرت سی مارتین

اما dependency inversion به طور عملی برای برنامه‌نویسان Go چه معنایی دارد؟

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

بنابراین فکر می‌کنم مارتین در اینجا در مورد ساختار گراف import کردن packageهای شما صحبت می‌کند که مطمئنا در زمینه Go است.

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

همه چیز برابر است، import graph یک برنامه‌ی Go به خوبی طراحی شده باید گسترده و نسبتاً مسطح باشد، نه بلند و باریک. اگر packageهای دارید که توابع آن بدون کمک گرفتن از packageهای‌ دیگری نمی‌توانند کار کنند، این احتمالاً نشانه‌ای است که کد به خوبی در امتداد مرزهای بسته فاکتور نشده است.

اصل وارونگی وابستگی شما را تشویق می‌کند که مسئولیت جزئیات را تا حد امکان به بالای import graph به packageهای‌ اصلی یا هندلر سطح بالا، منتقل کنید و اجازه دهید کد سطح پایین با انتزاعات - اینترفیس‌‌ها (abstractions–interfaces) سروکار داشته باشد.

9.7.6.1 مثال #

فرض کنید یک struct Worker داریم که نماینده یک Worker در یک شرکت است و یک struct Supervisor که نماینده یک سرپرست یا Supervisor است: ‍‍

 1 type Worker struct {
 2  ID int
 3  Name string
 4}
 5
 6func (w Worker) GetID() int {
 7  return w.ID
 8}
 9
10func (w Worker) GetName() string {
11  return w.Name
12}
13
14type Supervisor struct {
15  ID int
16  Name string
17}
18
19func (s Supervisor) GetID() int {
20  return s.ID
21}
22
23func (s Supervisor) GetName() string {
24  return s.Name
25}

اکنون، برای این حالت ضد الگو، فرض کنید یک بخش ماژول سطح بالا داریم که نشان دهنده یک بخش در یک شرکت است و نیاز به ذخیره اطلاعات در مورد workerها و supervisorها دارد که ماژول‌های سطح پایین در نظر گرفته می‌شوند:

1type Department struct {
2  Workers []Worker
3  Supervisors []Supervisor
4}

طبق اصل وارونگی وابستگی، ماژول های سطح بالا نباید به ماژول های سطح پایین وابسته باشند. در عوض، هر دو باید به انتزاعات بستگی داشته باشند. برای اصلاح مثال ضد الگوی خود، می توانم یک Interface Employee ایجاد کنم که نماینده هر دو، workerها و supervisorها باشد:

1type Employee interface {
2  GetID() int
3  GetName() string
4}

اکنون می توانم ساختار Department را به روزرسانی کنم تا دیگر به ماژول‌های سطح پایین وابسته نباشد:

1type Department struct {
2  Employees []Employee
3}

و حالت نهایی:

 1package main
 2
 3import "fmt"
 4
 5type Worker struct {
 6  ID   int
 7  Name string
 8}
 9
10func (w Worker) GetID() int {
11  return w.ID
12}
13
14func (w Worker) GetName() string {
15  return w.Name
16}
17
18type Supervisor struct {
19  ID   int
20  Name string
21}
22
23func (s Supervisor) GetID() int {
24  return s.ID
25}
26
27func (s Supervisor) GetName() string {
28  return s.Name
29}
30
31type Employee interface {
32  GetID() int
33  GetName() string
34}
35
36type Department struct {
37  Employees []Employee
38}
39
40func (d *Department) AddEmployee(e Employee) {
41  d.Employees = append(d.Employees, e)
42}
43
44func (d *Department) GetEmployeeNames() (res []string) {
45  for _, e := range d.Employees {
46    res = append(res, e.GetName())
47  }
48  return
49}
50
51func (d *Department) GetEmployee(id int) Employee {
52  for _, e := range d.Employees {
53    if e.GetID() == id {
54      return e
55    }
56  }
57  return nil
58}
59
60func main() {
61  dep := &Department{}
62  dep.AddEmployee(Worker{ID: 1, Name: "John"})
63  dep.AddEmployee(Supervisor{ID: 2, Name: "Jane"})
64
65  fmt.Println(dep.GetEmployeeNames())
66
67  e := dep.GetEmployee(1)
68  switch v := e.(type) {
69  case Worker:
70    fmt.Printf("found worker %+v\n", v)
71  case Supervisor:
72    fmt.Printf("found supervisor %+v\n", v)
73  default:
74    fmt.Printf("could not find an employee by id: %d\n", 1)
75  }
76}

این اصل وابستگی وارونگی را نشان می‌دهد، زیرا ساختار Department به‌جای یک پیاده‌سازی خاص (ساختار Worker یا Supervisor) به یک انتزاع (Employee interface) وابستگی دارد. این امر کد را انعطاف‌پذیرتر می‌کند و نگهداری آن را آسان‌تر می‌کند، زیرا تغییرات در اجرای workers و supervisors بر ساختار Department تأثیر نمی‌گذارد.

9.7.7 طراحی SOLID در Go #

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

اصل مسئولیت واحد (Single Responsibility) شما را تشویق می‌کند تا توابع، انواع و متدها را در بسته‌هایی ساختار دهید که انسجام طبیعی دارند؛ تایپ‌ها با هم مرتبط هستند، توابع یک هدف واحد دارند.

اصل باز/بسته (Open / Closed) شما را تشویق می‌کند تا تایپ‌های ساده را با استفاده از جاسازی به انواع پیچیده‌تر ترکیب کنید.

اصل جایگزینی لیسکوف (Liskov Substitution) شما را تشویق می‌کند تا وابستگی‌ها بین بسته‌های خود را از نظر اینترفیس‌ها بیان کنید و نه فقط تایپ‌های concrete. با تعریف اینترفیس‌های کوچک، می‌توانیم مطمئن‌تر باشیم که پیاده‌سازی‌ها به طور کامل قرارداد خود را برآورده می‌کنند.

اصل جداسازی اینترفیس (Interface Substitution) این ایده را بیشتر جلو می‌برد و شما را تشویق می‌کند تا توابع و متدهایی را تعریف کنید که فقط به رفتاری که نیاز دارند وابسته هستند. اگر تابع شما فقط به یک پارامتر از نوع Interface با یک متد نیاز دارد، پس احتمال بیشتری دارد که این تابع فقط یک مسئولیت داشته باشد.

اصل وارونگی وابستگی (Dependency Inversion) شما را تشویق می‌کند که دانش چیزهایی که package شما به آن‌ها وابسته است را از زمان کامپایل - در Go این را با کاهش تعداد عبارات import استفاده شده توسط یک package خاص می‌بینیم - به زمان اجرا منتقل کنید.

اگر بخواهید این صحبت را خلاصه کنید، احتمالاً این خواهد بود: Interface‌ها به شما اجازه می‌دهند اصول SOLID را به برنامه‌های Go اعمال کنید.

زیرا Interface‌ها به برنامه‌نویسان Go اجازه می‌دهند تا توصیف کنند که package آن‌ها چه چیزی را فراهم می‌کند - نه اینکه چگونه این کار را انجام می‌دهد. این همه فقط یک روش دیگر برای گفتن “decoupling” بوده که در واقع هدف اصلی ما است، زیرا نرم‌افزاری که به صورت پیوستگی ضعیف ( loosely coupled) شده است نرم‌افزاری است که تغییر آن آسان‌تر است.

همانطور که Sandi Metz می‌گوید:

طراحی هنر چیدمان کدی است که باید امروز کار کند و برای همیشه آسان تغییر کند.

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

9.7.8 در پایان #

بیایید به سؤالی که این صحبت را با آن شروع کردم برگردیم؛ چند برنامه‌نویس Go در دنیا وجود دارد؟ این حدس من است:

تا سال 2020، 500000 توسعه‌دهنده Go وجود خواهد داشت.

  • من

نیم میلیون برنامه‌نویس Go با وقت خود چه خواهند کرد؟ خب، واضح است که آن‌ها مقدار زیادی کد Go خواهند نوشت و اگر صادق باشیم، همه آن خوب نخواهد بود و برخی کاملاً بد خواهند بود.

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

درون ++C، یک زبان بسیار کوچکتر و تمیزتر در تلاش برای بیرون آمدن است.

  • بیارنه استراوستراپ، طراحی و تکامل ++C

فرصت برای همه برنامه‌نویسان Go برای موفقیت زبان ما مستقیماً به توانایی جمعی ما در ایجاد چنین آشفتگی‌ای بستگی دارد که مردم شروع به صحبت کردن درباره Go به همان روشی کنند که امروز درباره ++C شوخی می‌کنند.

داستانی که زبان‌های دیگر را به دلیل بزرگ، پر حرفی و پیچیده بودن مورد تمسخر قرار می‌دهد، ممکن است روزی به سمت Go برگردد،و من نمی‌خواهم این اتفاق بیفتد، بنابراین درخواستی دارم.

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

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

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

… یک چیز دیگر

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

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

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

یک پست وبلاگ در مورد آن بنویسید. یک کارگاه در مورد کاری که انجام دادید تدریس کنید. یک کتاب در مورد آنچه آموخته‌اید بنویسید. و سال آینده به این کنفرانس برگردید و در مورد آنچه به دست آوردید صحبت کنید.

زیرا با انجام این کارها می‌توانیم فرهنگی از توسعه‌دهندگان Go ایجاد کنیم که به برنامه‌هایی اهمیت می‌دهند که برای ماندگاری طراحی شده‌اند.

متشکرم.