Swift - რა არის Method dispatch ი? რა ტიპის dispatch ები გვაქვს და როგორ მუშაობენ ისინი.
ოდესმე გიფიქრია თუ რა ხდება, როდესაც ფუნქციას ვეძახით?
არადა როგორი მარტივი ჩანს ხო? ვქმნით ფუნქციას, შემდეგ ვეძახით და ყველაფერი ჯადოსნური თავისით ხდება runtime ში. არადა რომ დავფიქრდეთ რამდენი კომპლექსურობაა თითოეული მეთოდის გამოძახების უკან.
გადატვირთული ფუნქციები, მემკვიდრეობითობა, მეხსიერების გამოყოფა, ოპტიმიზაცია, პარამეტრების დაკოპირება და ა.შ
დღეს სტატიაში ვისაუბრებთ თუ რა ტიპის method dispatch ები გვაქვს Swift ში, ვიმსჯელებთ მათი მუშაობის პრინციპის შესახებ. კომპილატორის ოპტიმიზაციებზე და იმაზე თუ რეალურად როგორ აპროცესებს method ის გამოძახებას თვითონ runtime ი, როგორ შეგვიძლია შევზღუდოთ compiler overhead ი და გავზარდოთ performance ი ჩვენს ყოველდღიურ საქმიანობაში.
რა არის Method dispatch ი ?
Method dispatch ს ვეძახით პროცესს, რომელიც სისტემას ჭირდება იმის დადგენისთვის, რომ მიხვდეს კონკრეტული ფუნქციის რომელი იმპლემენტაცია გამოიძახოს.
წარმოვიდგინოთ რომ გვაქვს მარტივი ფუნქცია, რომელსაც ვეძახით.
რა ხდება ამ დროს მის უკან ? როგორ ხვდება კომპილატორი თუ სად არის ეს მეთოდი ? როგორ ხვდება კომპილატორი თუ რომელ მეთოდს უნდა დაუძახოს თუ ეს ფუნქცია გადატვირთულია, ხოლო კლასს რამოდენიმე შვილობილი კლასი ყავს. ყველაფერ ამაში Method dispatch ები გვეხმარება, როგორც compile-time ში ასევე runtime ში.
Swift ში 3 სახის ფუნქციის dispatch ი გვაქვს.
- Static dispatch (direct dispatch)
- Table dispatch (virtual dispatch)
- Message dispatch
დავიწყოთ თითოეული dispatch ის ჩაშლით და სიღრმისეულად გარჩევით.
Static Dispatch
ფუნქცია, რომელსაც არ შეიძლება ყავდეს გადატვირთული ანუ overriden ვარიანტი, მასზე კომპილატორი სტატიკური dispatch ით ხელმძღვანელობს.
სტატიკურ dispatch ს ვეძახით, ფუნქციის ისეთ გამოძახებას სადაც runtime მა ზუსტად იცის ამ ფუნქციის მისამართი და დარწმუნებულია, რომ ამ ფუნქციას მხოლოდ ერთი იმპლემენტაცია აქვს, ასეთ დროს გაშვებულ აპლიკაციაში მარტივია რომ runtime ი გადახტეს პირდაპირ კონკრეტულ მეხსიერების ზონაზე და დაიწყოს ფუნქციის execution ი, განსხვავებით სხვა dispatch ებისგან.
მსგავსი dispatch ის მისაღწევად swift ში method ები შეგვიძლია გამოვაცხადოთ შემდეგი keyword ებით.
- static
- final
ასევე ყველა ის ფუნქცია, რომელიც value type ებშია გამოცხადებული default ად final მეთოდებია, რომლებიც როგორც ზემოთ ავღნიშნე სტატიკური dispatch ით მუშაობენ.
value typ ებს არ შეუძლიათ იყვნენ გადატვირთული, რადგან Swift ში აკრძალულია inheritance ბმა მათ შორის.
ამიტომ, როდესაც მაგალითად ვქმნით სტრუქტურას, სადაც გვაქვს ფუნქციები ამ დროს კომპილატორმა ზუსტად იცის, რომ ამ ფუნქციებს ვერასდროს ეყოლებათ გადატვირთული ფუნქციები, ამიტომ თამამად შეუძლია პირდაპირ მისამართით მიაკითხოს მათ.
Table Dispatch
ესეთი მეთოდის dispatch ი გამოიყენება default ად ყველა reference type ში გამოცხადებული ფუნქციებისთვის. (შეგახსენებთ Swift ში reference type ები არიან ყველა class და closure ი, ხოლო value type ები ყველა სტრუქტურა და enum ი.)
ესეთი dispatch ების დროს, compile time ში იქმნება virtual table ი რომელშიც ინახება კონკრეტული იმპლემენტაციები, კონკრეტული მეთოდების რომლებიც შემდეგ runtime ში გამოიძახება. Runtime ის დროს, virtual table ი უბრალოდ ფუნქციის პოინტერების მასივად გარდაიქმნება სადაც runtime ს შეუძლია ჩაიხედოს და კონკრეტული ლოკაცია ამოიღოს შესაბამისი ფუნქციის, შესაბამისი იმპლემენტაციისთვის.
მაგალითად, რომ წარმოვიდგინოთ ესეთი 2 პოლიმორფული ტიპის კლასი (პოლიმორფულია კლასი, თუ მას შეუძლია პოლიმორფიზმი სხვა კლასთან)
მაგალითში კარგად ჩანს, რომ გვყავს მშობელი კლასი რომელსაც აქვს ორი member ფუნქცია. შემდეგ გვყავს შვილობილი კლასი, რომელსაც აქვს მხოლოდ ერთი ფუნქცია გადატვირთული, მემკვიდრეობით მიღებული ერთი ფუნქცია, ერთიც საკუთარი ახალი დამატებული. ანუ მემორის დონეზე runtime ში შვილობილ კლას ჯამში აქვს 3 ფუნქცია.
ზემოთ მოყვანილი მაგალითების დროს, როგორ უნდა მიხვდეს runtime ი თუ რომელი ფუნქციის იმპლემენტაცია გამოიძახოს ? Runtime ი ზუსტად compile-time ში დაგენერირებული virtual table ის მიხედვით მიხვდება, თუ რომელი იმპლემენტაცია უნდა გამოიძახოს.
მოდი ვნახოთ სტრუქტურულად როგორ შეიძლება გამოიყურებოდეს Virtual table ი
Compile-time ში ჩვენი კოდის კომპილაციის დროს, ზუსტად ესეთი Virtual-table ი შეიქმნება მეხსიერებაში. სადაც method1 ს და method2 ს child ი მემკვიდრეობით იღებს, ხოლო თვითონ method3 ი შეძენილი ფუნქციაა, რომელსაც არაფერი კავშირი არ აქვს Parent თან.
ჩვენს შემთხვევაში Child კლასს მხოლოდ method2 ფუნქცია აქვს გადატვირთული რაც იმას ნიშნავს, რომ child ობიექტიდან method2 ის გამოძახება 0x227 მისამართზე წავა, ოღონდ 0xB000 მისამართიდან და შემოწმდება თუ ამ მისამართზე არის Child ის მიერ იმპლემენტირებული ეს ფუნქცია, თუ არის მოხდება პირდაპირ გამოძახება. თუ არა მაშინ runtime ი Child ის სუპერ კლასთან ანუ Parent თან წავა და იქ შეამოწმებს 0x227 მისამართზე თუ არის იმპლემენტაცია და თუ დახვდა მაშინ გამოიძახებს. ჩვენს შემთხვევაში method2 ის გამოძახება Child ში მოხდება, მაგრამ method1 ის გამოძახება child ობიექტიდან გამოიწვევს ზევით აღწერილ flow ს სადაც runtime ი იერარქიულად ეძებს იმპლემენტაციას და არ გამოიძახებს ფუნქციას იქამდე სანამ virtual table ში არ იპოვის შესაბამის მისამართზე რომელიმე იერარქიაში მის იმპლემენტაციას.
V-table ი compile-time ის დროს იქმნება, როდესაც SIL ი გენერირდება. (Swift intermediate language), ხოლო ფუნქციის იმპლემენტაციების ამორჩევის პროცესი რა თქმა უნდა runtime ში ხდება.
აქვე ვნახოთ დაგენერირებული SIL — ი ჩვენი Vtable ის მიხედვით
იმის მიუხედავად, რომ SIL ის წაკითხვა საკმაოდ ძნელია, მაინც შეგვიძლია დაკვირვების შემდეგ ვნახოთ, რომ Child კლასსაც და Parent კლასსაც საკუთარი Virtual table ები აქვთ, სადაც არის გადანაწილებული მათი ფუნქციები, მეხსიერების ალოკაცია და მეხსიერების დეალოკაცია.
თუ დააკვირდებით მემკვიდრეობით მიღებულ ყველა ფუნქციას აქვს @$SilGen10Parent ატრიბუტი, გარდა method2 ისა, რადგან შვილობილი კლასი ამ ფუნქციის გადატვირთვას ანუ override ს აკეთებს. ზუსტად ესე ხვდება runtime ი კონკრეტულ იერარქიაში აქვს თუ არა მეთოდს იმპლემენტაცია.
Swift ის ფაილის SIL ში გადასაყვანად, ეს ბრძანება უნდა გაუშვათ ტერმინალში. დამიჯერეთ, ქვევით ბევრი საინტერესო დეტალი დაგხვდებათ.
swiftc -emit-silgen -O <swift-file-name>.swift
ზემოთ აღწერილი Dispatch მიდგომა მუშაობს default ად ყველა reference type ისთვის, ამიტომ ამ dispatch ის მისაღწევად Swift ში არაფრის დაწერა არ გვიწევს, რადგან ყველა კლასის ფუნქცია default ად ამ მექანიზმით მუშაობს.
Message Dispatch
ჩვენ უკვე გავარჩიეთ Table dispatch ის შემთხვევაში როგორ წყვიტავდა runtime ი თუ რომელი ფუნქცია გამოეძახა მემორიდან, მაგრამ ამ შემთხვევაში წოტა უფრო დიდი პრობლემა გვაქვს.
თუმცა ეს პრობლემა რომ გავიგოთ, წოტა ისტორია და კონტექსტი უნდა ვიცოდეთ. ამიტომ წოტა დროში მოგზაურობა მოგვიწევს.
ძალიან დიდი ხნის წინ.
Objective-c ძალიან runtime ზე დამოკიდებული ენაა, runtime ის დროს უამრავი კოდის შემოწმება ხდება და ამას პლიუს ისიც კი შეგიძლია რომ ფუნქციის იმპლემენტაციაც კი შეცვალო runtime ში. ამას Method swizzling ს ვეძახით.
ენის ერთ-ერთი პლიუსია სწრაფი compile-time ი და ეს ზუსტად იმისგან მიიღეს, რომ ბევრი შემოწმება და რუტინული საქმე runtime ის პასუხისმგებლობა გახადეს. მაგალითად runtime ი აკეთებს ისეთ რაღაცეებს objective-c ში როგორიცაა:
- კლასის შემოწმება თუ ის რაღაც X კლასის ნაწილია, ფუნქცია isMemberOfClass ით. ასევე runtime ი ამოწმებდა თუ რომელიმე X კლასისგან იყო წარმოქმნილი კონკრეტული კლასი isKindOfClass ფუნქციით.
- შემოწმება თუ კლასს შეეძლო კონკრეტული message ის მიღება და შემდეგ დაპროცესება. respondToSelector ფუნქციით
- დინამიურად ცვლიდა runtime ი method ის იმპლემენტაციას (method swizzling)
- ასევე შეეძლო დაემატება ფუნქციის იმპლემენტაცია class_addMethod ფუნქციით.
ეხლა უკვე ვიცით, რომ objective-c ძალიან runtime ზე დამოკიდებული ენაა და compile time ში მყოფი პროგრამის სტეიტი საერთოდ არაა სანდო, რადგან runtime ს შეუძლია თითქმის ყველაფრის შეცვლა. ამან შეიძლება ძალიან დიდი პრობლემა შეგვიქმნას თუ Table dispatch ტექნიკას გამოვიყენებთ იმისთვის, რომ გავიგოთ თუ რომელ ფუნქციის იმპლემენტაციას დავუძახოთ. რატომ ?
V-table ის შექმნა Table dispatch ის დროს ხდება compile-time ში. Compile-time ში შექმნილ V-Table ი შეიძლება სწორად არ ასახავდეს რომელიმე მეთოდის იმპლემენტაციას, რადგან შეიძლება სამომავლოდ მოხდეს მათზე swizzling ი, ან შეიძლებ ახალი ფუნქციები დაემატონ runtime ში. გამომდინარე აქედან compile-time ში მიღებული გადაწვეტილება ვერ იქნება საყრდენი წერტილი ისეთი ენისთვის როგორიცაა Objective-c.
ამიტომ Message dispatch ი მთლიანად runtime ზეა დამოკიდებული, და კონკრეტულად რაიმე v-table ი აქ არ გვაქვს.
ეხლა როგორ ეძებს ფუნქციას Message dispatch ი ?
Objective-C Alan Key ს იდეებს მიყვებოდა და ბევრჯერ ინსპირაცია Smalltalk იდან იყო აღებული, ამიტომ ობიექტები ერთმანეთს message ების მიმოცვლით ესაუბრებოდნენ. იმისთივს, რომ A ობიექტს B ობიექტის ფუნქციისთვის დაეძახა, B ობიექტისთვის მესიჯი უნდა გაეგზავნა.
ამ ყველაფერს objc_msgSend() ფუნქცია აკეთებს.
კონკრეტული ფუნქცია 3 პარამეტრს იღებს
- მიმღები ობიექტი
- Message ის selector ი (ფუნქციის სახელი, რომელიც target object ში უნდა გამოიძახოს)
- არგუმენტები
მიმღების ობიექტს isa პოინტერი აქვს. Selector ები კი v-table ში ინახება. objc_msgSend() ფუნქცია მიყვება isa პოინტერს, რომ იპოვოს შესაბამისი სადაც არის გამოყოფილი მეხსიერება ამ მისამართით. თუ ვერ იპოვის მსგავს განყოფილებას მაშინ კლასის სუპერ კლასს ის პოინტერს იღებს და მასში იწყებს მეთოდის ძებნას. ბოლოს NSObject ამდე ადის და თუ მაინც ვერ მოხდა იმპლემენტაციის პოვნა, exception ი მოხდება. რა თქმა უნდა ეს მიდგომა ყველა dispatch თან შედარებით ყველაზე ნელია.
iOS დეველოპერები დღეს იძულებულები ვართ რომ გამოვიყენოთ objective-c ის runtime ი რამოდენიმე ადგილას. ყველაზე ცნობილია ისეთ ადგილები სადაც Target-action მექანიზმი გვჭირდება. Target-action მექანიზმი UIKit ში ისევ Objective-C შია დაწერილი, ამიტომ ნებისმიერ დროს როდესაც მაგალითად ჩვეულებრივ UIButton ს addTarget ფუნქციას იძახებთ. იმისთვის რომ Selector ს გადასცეთ ფუნქცია გიწევთ ფუნქციას წინ ატრიბუტი გაუკეთოთ @objc, რაც იმას ნიშნავს, რომ ამ ფუნქციის გამოძახება მოხდება obj-c runtime ში.
როგორ შეიძლება რომ Swift ში კონკრეტული ფუნქციები objective-c runtime ში გავუშვათ?
ამისთვის ორი ატრიბუტი გვაქვს
- @objc
- dynamic
განსხვავება საკმაოდ დიდია, @objc ით მონიშნული ფუნქციები დანახვადი იქნება obj-c runtime ისთვის. ასევე გაეშვება ამავე runtime ში, მაგრამ swift ი ამ ფუნქციებისთვის ან static dispatch ს ან table dispatch ს გამოიყენებს. თუ ამ კონკრეტულ ფუნქციაზე, objective-c ში swizzling ი მოხდება, საერთოდ სხვა შედეგებს მივიღებთ ან შეიძლება ქრაში მივიღოთ.
მეორეს მხრივ dynamic keyword ის გამოყენებით, Swift ს ვეუბნებით რომ ეს კონკრეტული ფუნქცია ყოველთვის Message dispatch ით იქნას გამოყენებული. ეს აუცილებელია როცა Key-value observing ს ვაკეთებთ, ან უბრალოდ როცა გვინდა რომ runtime ში მეტი დინამიურობა გვქონდეს. არ უნდა დაგვავიწყდეს, რომ მსგავის ტიპის dispatch ი performance ის მხრივ ყველაზე ნელია.
მარტივად რომ დავამტკიცოთ dynamic ის Message dispatch ობა, მსგავის მაგალითი შეგვიძლია გავაკეთოთ.
- გვაქვს Shape კლასი, რომელსაც აქვს draw ფუნქცია
- ვაკეთებთ Shape ის extension ს სადაც ვამატებთ ერთ ფუნქციას redraw
- ვქმნით შვილობილ კლასს სადაც ვცდილობთ redraw ის გადატვირთვას
ამ ყველაფრის შემდეგ override func redraw ფუნქციასთან მოგვდის compile-time შეცდომა რომელიც ესე გამოიყურება
რატომ გვაძლევს შეცდომას ? თუ აქამდე არ იცოდით, extension method ები default ად static dispatch ს იყენებენ performance ისთვის, როგორც ზევით ვახსენეთ ისეთი მეთოდები რომელიც სტატიკური დისპაჩით მუშაობენ არ იტვირთებიან, ანუ შეუძლებელია მათზე override ის გაკეთება. თუმცა მოდი ვნახოთ როგორ შეიძლება Message dispatch ით და objective-c ის runtime ის ჩარევით ამ პრობლემის მოგვარება.
როგორც კი ფუნქციას მივუთითებთ, რომ ის objective-c ის runtime ში გვინდა გაეშვას, Swift ის static dispatch ი თმობს ამ ფუნქციაზე მიმთითებელს, და უკვე შეგვიძლია კონკრეტული ფუნქციის შვილობილ კლასებში გადატვირთვა. თუმცა ეს არღვევს ენის კანონებს, რადგან Apple ი გვეუბნება, რომ extension method ები ფუნქციონალის დამატებისთვისაა და არა მემკვიდრეობითი ფუქნციონალის შექმნისთვის. თუმცა ეს მიდგომა გამოსადეგია როცა ძველ და legacy კოდზე ვმუშაობთ და ახალი ფუნქციონალის დამატებისთვის არ გვინდა ძველი კოდის ბევრ ადგილას შეცვლა.
მოდი ეხლა გავიაროთ ყველა ის dispatch modifier ი რომელიც Swift ში გვაქვს, რომ პრაქტიკული სახით შევაჯამოთ დღევანდელი სტატია.
final
final modifier ი swift ს აიძულებს, რომ კონკრეტული ფუნქცია static dispatch ით იქნას გამოძახებული. final modifier ი უდიდეს როლს ასრულებს performance ის გაზრდაში. ეხლა მოდით დაფიქრდით და აღიარეთ, თქვენს პროექტებში რამდენი კლასი გიწერიათ, რომელიც არ იყენებს inheritance ს და რომლის მეთოდებიც არსად არ არის გადატვირთული ? დარწმუნებული ვარ ესეთი ბევრი იქნება.
არადა Swift ი იმის გამო, რომ კლასი reference ტიპია ესეთ მეთოდებს Table dispatch ით ემსახურება, რომელიც ზევით ავხსენით. რა მოხდება თუ კლასს final ად მონიშნავთ? ამით კომპილატორს ეტყვით რომ ჩემს კლასს მომავალში შვილი არ ეყოლება, რაც იმას ნიშნავს რომ ჩემი ფუნქციები არასდროს არ იქნებიან გადატვირთულები, რაც ავტომატურად ნიშნავს ამ ფუნქციების static dispatch ად მომსახურებას, რომელიც ყველაზე სწრაფია.
dynamic
როგორც ზევით ვახენეთ dynamic modifier ი Swift ს უბრძანებს, რომ ეს კონკრეტული ფუნქცია message dispatch ით იქნას დამუშავებული Objective-c ის runtime ში. dynamic ის გამოყენებისთვის მოგიწევთ Foundation ის დაიმპორტება, რაც თავისთავად objective-c ს რანთაიმსაც წამოიღებს თქვენს სამყაროში.
@objc
objc modifier ი არ განაზოგადებს კონკრეტულ dispatch სტრატეგიას, მაგრამ ცალსახად ამბობს რომ კონკრეტული ფუნქცია objective-c ის runtime ში გაეშვას. თუ ამ ფუნქციას extension მეთოდად დავამატებთ, ცალსახად ის static dispatch ს გამოიყენებს, თუ უბრალოდ კლასში დავამატებთ მაშინ Table dispatch ს. აქაც მარტივი Trick and Tip სი იქ იქნება, რომ ღილაკისთვის გამზადებული selector ფუნქციები ყოველთვის extension ებად გავიტანოთ, რადგან არ მოხდეს Table dispatch ის გამოყენება.
@inline
inline ფუნქციები C/C++ ში გამოირჩევიან სისწრაფით და მცირე execution time ით, ჩვენ შეგვიძლია ყველა ფუნქციას წინასწარ გავუწეროთ @inline(always) ან @inline(never) რაც სავსებით ლოგიკურია რასაც ნიშნავს. თუმცა ხშირ შემთხვევაში ეს არ გვჭირდება, რადგან Swift ის კომპაილერი ძალიან ჭკვიანია და თვითონ ხვდება თუ რომელი ფუნქცია გახადოს inline და რომელი არა, ჩვენი ჩარევის გარეშე, ამიტომ შესაბამისად ეს ატრიბუტი არც თუ ისე გამოყენებადია ყოველდღიურ development ში. ასევე Swift 5 ში დაემატა ატრიბუტი @inlinable რომელიც კონკრეტულად Framework ებისთვისაა, რომლებსაც ჩვენ ვქმნით ცალკე მოდულებად. კომპილატორს ჩვენი კოდის დაკომპილირებისას მარტივად შეუძლია მიიღოს გადაწვეტილება ფუნქცია inline გახადოს თუ არა, მაგრამ როდესაც framework ებზეა საუბარი წინასწარ არ ვიცით ჩვენი framework ის გამომყენებელი როგორ გამოიყენებს ჩვენს მიცემულ ფუნქციებს, ამიტომ @inlinable ით წინასწარ შეგვიძლია განვსაზღვროთ რა შეიძლება იყოს inline და რა არა. inline ზე მეტის წაკითხვისთივს ამ ბმულს ეწვიეთ.
არ დაგვავიწყდეს, რომ ყველა ფუნქცია რომელიც Class ში იქნება გამოცხადებული, default ად Table dispatch ს გამოიყენებს.
Conclusion
საბოლო ჯამში, 3 ტიპის dispatch ზე ვისაუბრეთ. მინიმუმ იმას ვეცადე, რომ გაგვეგო თუ რომელი როდის გამოვიყენოთ და რა tradeoff ებთან გვაქვს საქმე. ეს სტატია ასევე შეგიძლიათ გამოიყენოთ coding interview ებზე. თითქმის ყველა გასაუბრებაზე სვავენ კითხვას თუ რა განსხვავებაა class სა და struct ს შორის, თუ ყველა ნაცნობი მაგალითის გარდა static და table dispatch საც ახსენებთ, დამიჯერეთ საკმაოდ მაღლა გამოჩნდებით გამსაუბრებლის თვალში.
თუ სიღრმისეულად გაინტერესებთ უფრო კონკრეტულად რას აკეთებს ჩვენთვის ნაცნობი runtime ები, გირჩევთ რომ SIL ს ჩახედოთ, ნელ ნელა მისი წაკითხვაც უფრო და უფრო გაგიმარტივდებათ.
და ბოლოს, არ დაგავიწყდეთ final keyword ი ისეთ კლასებთან, რომლებიც არასდროს არიან მემკვიდრეობით იერარქიაში. დამიჯერეთ, ერთ და ორი კლასის final ად შეცვლა არ მოგცემთ ისეთ შედეგს, რომ თვალით შეამჩნიოთ მაგრამ როდესაც უზარმაზარი codebase ი გაქვთ, მემკვიდრეობითობის იერარქიის სწორად დალაგება და არა საჭირო კლასებზე final ის მითითება საგრძნობლად დიდ შედეგს მოგიტანთ performance ში.
მადლობა