რატომ კიდევ ერთი სისტემური პროგრამირების ენა?
Rust-მა პრაქტიკაზე დაამტკიცა, რომ უსაფრთხო სისტემური პროგრამირების ენების შექმნა garbage collector-ის გარეშე შესაძლებელია და ეს ნიშა ძალიან მოთხოვნადია.
C/C++-ზე დაწერილმა პროგრამებმა ათწლეულების განმავლობაში ათასობით კრიტიკული შეცდომა დააგროვეს, რის გამოც ხდებოდა უამრავი exploit-ის შექმნა, სისტემების გატეხვა და ასე შემდეგ. მეხსიერების ბაგები არის ექსპლოიტების ერთ ერთი ყველაზე მთავარი წყარო. გარდა ამისა მეხსიერების ბაგების გამო პროგრამები შეიძლება არასტაბილური იყოს, იქრაშებოდეს და ასე შემდეგ.
მაგრამ Rust-ს თავისი ფასი აქვს. ის ძალიან რთულია და მისი იმპლემენტაციის სპეციფიკიდან გამომდინარე უსაფრთხო მეხსიერების რეალიზაცია მხოლოდ კომპილატორის დონეზე არ ხდება, არამედ გადადის თვითონ ენაში და ტიპების სისტემაშიც, რის გამოც ის არ არის კითხვადი და ძალიან რთული ასათვისებელია სემანტიკურად.
Buk-ის შექმნის დროს ჩემს თავს დავუსვი ასეთი კითხვა:
შეიძლება თუ არა Rust-ის მსგავსი compile-time memory safety მივიღოთ უფრო პატარა source-level მოდელით?
ანუ სირთულე კი არ გავაქროთ, რაც შეუძლებელია, არამედ მისი დიდი ნაწილი კომპილატორში გადავიტანოთ. ამასთან ერთად გავასწოროთ Rust-ის მიერ დაგროვებული tech debt-ი.
Ownership და Borrow Checker
სანამ დეტალებზე გადავიდოდეთ, ორი ძირითადი ტერმინი განვმარტოთ.
Ownership ნიშნავს, რომ კონკრეტულ value-ს ჰყავს owner ("მფლობელი"), ანუ პროგრამის ის ნაწილი, რომელიც მასზე და მის რესურსებზეა პასუხისმგებელი.
let document = LoadDocument();
აქ ცვლადი document არის owner (LoadDocument-ის მიერ დაბრუნებული მნიშვნელობის ექსკლუზიური "მფლობელი").
თუ document ცვლადში არსებული მნიშვნელობა სხვა ფუნქციას მთლიანად გადავეცით, ownership-იც მასზე გადადის და ის ხდება ექსკლუზიური მფლობელი.
Publish(take document);
ამის შემდეგ ძველი მფლობელი ამ მნიშვნელობას ვეღარ გამოიყენებს.
Borrowing ნიშნავს მნიშვნელობის დროებით გამოყენებას ownership-ის გადაცემის გარეშე, ანუ "თხოვებას".
მაგალითად:
PrintSummary(document);
აქ ფუნქციას-ს მხოლოდ დროებითი წვდომა აქვს, ხოლო ექსკლუზიური მფლობელი ისევ document ცვლადი რჩება.
Borrow შეიძლება იყოს მხოლოდ წასაკითხად, ან exclusive mutation-ისთვის.
Buk-ში ეს სამი ოპერაცია შემდეგნაირად გამოიხატება:
Look დროებითი read access (მხოლოდ გამოყენება/წაკითხვა)
Change დროებითი exclusive mutation (გადავცემთ ექსკლუზიურ წვდომას რათა გამომყენებელმა შეძლოს რესურსის მნიშვნელობის შეცვლა, მისი მუდმივი ფლობის გარეშე)
Take (ექსკლუზიური მფლობელობის სამუდამო გადაცემა)
კომპილატორი ამ ყველაფერს კომპილაციის დროს აკონტროლებს და ამოწმებს, რომ არც dangling reference შეიქმნას, არც უკვე გადაცემული მნიშვნელობა გამოვიყენოთ და არც conflicting access მოხდეს.
შესაბამისად, კიდევ ეღთხელ:
Buk-ი იყენებს manual memory management-ს, Garbage Collector-ის გარეშე.
Memory safety-ს კომპილატორი აკონტროლებს ownership და borrowing მოდელის საშუალებით.
ყველა owning value-ს ჰყავს owner ("მფლობელი").
კომპილატორი კომპილაციის დროს აკვირდება:
- სად შეიქმნა მნიშვნელობა
- ვინ ფლობს მას მოცემულ მომენტში
- სხვა ფუნქციას ownership გადაეცა თუ მხოლოდ დროებითი access / წვდომა
- ვის აქვს read access
- ვის აქვს mutation უფლება
- არსებობს თუ არა მის storage-ზე დამოკიდებული reference ან view
- როდის მთავრდება borrow-ის ბოლო რეალური გამოყენება
- შეიძლება თუ არა storage-ის invalidation ან გადაადგილება
- ვის ეკისრება საბოლოოდ რესურსზე და მის გათავისუფლებაზე პასუხისმგებლობა
მაგალითად:
let sql = GenerateSQL();
GenerateTableMappings(sql);
Normalize(change sql);
let plan = Compile(take sql);
ერთი შეხედვით ჩვეულებრივი კოდია.
მაგრამ კომპილატორისთვის ამ ოთხ ხაზში სამი განსხვავებული ოპერაცია ხდება.
Look
Look ნიშნავს დროებით shared access-ს ownership-ის გადაცემის გარეშე.
func PrintSummary(document: Document)
{
Print(document.Title);
}
Call site:
let document = LoadDocument();
PrintSummary(document);
PrintSummary(document);
PrintSummary(document) არ ნიშნავს, რომ Document დაკოპირდა.
არც ownership გადასულა.
ფუნქციამ უბრალოდ მიიღო დროებითი read access.
ფუნქციის დასრულების შემდეგ document ისევ გამომძახებელს ეკუთვნის.
Buk-ში ყველაზე ხშირი ოპერაცია სწორედ Look-ია, ამიტომ ის საერთოდ არ იწერება.
ანუ:
PrintSummary(document);
უკვე ნიშნავს read-only borrow-ს.
არ გვჭირდება:
PrintSummary(look document);
Change
Change ნიშნავს დროებით exclusive mutable access-ს.
Ownership ისევ გამომძახებელს რჩება.
func IncreaseRevision(change document: Document)
{
document.Revision = document.Revision + 1;
}
Call site:
IncreaseRevision(change document);
change დაახლოებით ასე შეიძლება წავიკითხოთ:
„ეს მნიშვნელობა დროებით შენ გაბარია შესაცვლელად, მაგრამ ownership ჩემი რჩება.“
სანამ ასეთი borrow აქტიურია, კომპილატორი იმავე მნიშვნელობაზე conflicting access-ს არ დაუშვებს.
როგორც კი Change borrow-ის ბოლო რეალური გამოყენება დასრულდება, ორიგინალური მფლობელი მნიშვნელობას ჩვეულებრივად იყენებს.
IncreaseRevision(change document);
PrintSummary(document);
აქ mutation მომენტი კარგად ჩანს როგორც ფუნქციის დეკლარაციაში, ასევე გამოძახების დროსაც.
ეს შეგნებულად ასეა.
კოდის მკითხველმა ფუნქციის გამოძახების დანახვისას უნდა იცოდეს, რომ ამ გამოძახებას document-ის შეცვლა შეუძლია.
Take
Take ownership-ის სრულ გადაცემას ნიშნავს.
func Publish(take document: Document)
{
// ეს ფუნქცია ახლა ფლობს document-ის სრულ კონტროლს
}
Call site:
let document = LoadDocument();
PrintSummary(document);
Publish(take document);
ამის შემდეგ ძველი მფლობელი ამ მნიშვნელობას (document-ს) ვეღარ გამოიყენებს:
Publish(take document);
PrintSummary(document); // compile-time error
Runtime error არ ხდება.
კომპილატორმა უკვე იცის, რომ document-ის ownership გადავიდა Publish ფუნქციაში.
take დაახლოებით ნიშნავს:
„ამ მნიშვნელობის ownership მთლიანად შენ გადმოგეცი.“
ეს განსაკუთრებით მნიშვნელოვანია რესურსებთან მუშაობისას, რადგან კომპილატორს ზუსტად შეუძლია იცოდეს, ვის ეკისრება კონკრეტულ resource-ზე საბოლოო პასუხისმგებლობა. პირობითად ოპერაციული სისტემის რომელიმე რესურსზე (პროცესი, HANDLE, სოკეტი ან სხვა).
Look, Change და Take ერთად
ამიტომ ტიპური Buk-ის კოდი შეიძლება ასე გამოიყურებოდეს:
let document = LoadDocument();
Validate(document);
PrintSummary(document);
Normalize(change document);
PrintSummary(document);
Publish(take document);
აქ კომენტარები პრაქტიკულად არც არის საჭირო.
ჩვეულებრივი გამოძახება ნიშნავს "ნახვას" / "გამოყენებას".
change ნიშნავს შეცვლას.
take ნიშნავს ownership-ის გადაცემას.
კოდში პირდაპირ ჩანს სრული სემანტიკა.
Buk vs Rust
Rust-ში დაახლოებით იგივე მაგალითი ასე გამოიყურებოდა:
fn inspect(document: &Document) {}
fn normalize(document: &mut Document) {}
fn publish(document: Document) {}
Call site:
inspect(&document);
normalize(&mut document);
publish(document);
Buk-ში:
func Inspect(document: Document) {}
func Normalize(change document: Document) {}
func Publish(take document: Document) {}
Call site:
Inspect(document);
Normalize(change document);
Publish(take document);
ფუნდამენტური იდეა ორივეგან მსგავსია: ownership, shared borrow და exclusive mutable borrow.
განსხვავება უფრო რეალიზაციის მოდელშია.
Rust-ი კლასიკურ pointer/reference სემანტიკას იყენებს.
&T
&mut T
Buk-ი კი პროგრამისტის განზრახვას ძირითადად ოპერაციის დონეზე აჩვენებს:
value
change value
take value
Lifetimes
Borrow Checker-ის მეორე მნიშვნელოვანი ნაწილი lifetimes-ია.
თუ ფუნქცია მნიშვნელობას არ ფლობს და მხოლოდ "ნათხოვარი" აქვს (უჭირავს როგორც borrow), კომპილატორმა უნდა იცოდეს, რამდენ ხანს შეიძლება ამ borrow-ის გამოყენება და რომელ storage-ზეა დამოკიდებული.
Rust lifetimes-ის უმეტესობას კომპილატორი თავისით ხვდება, მაგრამ ზოგიერთ უფრო კომპლექსურ კოდში Rust-ის კომპილატორს ჭირდება "ჰინტები" რაც კოდში უნდა დაიწეროს როგორც ფუნქციის დეკლარაციაში, ასევე გამოყენებულ ტიპებში. "'"-ის სახით. მაგალითად:
fn pick<'a>(
a: &'a str,
b: &'a str,
first: bool,
) -> &'a str
{
if first { a } else { b }
}
Buk-ს public lifetime syntax საერთოდ არ აქვს.
არ გვაქვს:
'a
'b
'static
კომპილატორი თვითონ ადგენს, საიდან მოდის მნიშვნელობა და რაზეა მისი lifetime დამოკიდებული.
მაგალითად:
func Name(user: User): StringView
{
return user.Name.AsView();
}
კომპილატორს შეუძლია დაადგინოს, რომ დაბრუნებული StringView user-ის backing storage-ზეა დამოკიდებული.
პროგრამისტს ამის ხელით აღწერა არ სჭირდება.
მთავარი კომპილატორის მაგია. საიდან მოაქვს Buk-ს ინფორმაცია Lifetime-ების შესახებ, თუ კი კოდში არ ვწერთ "'"-ებს ?
აქ ჩნდება .bki, ანუ Buk Package Interface.
.bki არის კომპლექსური მეტადატის შემცველი ფაილი, რომელსაც კომპილატორი აგენერირებს.
მასში ინახება კოდის სემანტიკური კონტრაქტები.
მაგალითად კომპილატორს შეიძლება დასჭირდეს ცოდნა იმის შესახებ, რომ:
დაბრუნებული მნიშვნელობის შენახვა ხდება რომელიღაც backing storage-ში (მაგალითად კოლექცია "UserList"-ის მეხსიერებაში)
ფუნქცია მხოლოდ კონკრეტულ ველს ცვლის
მნიშვნელობა await-ის იქით ვერ გადავა
ფუნქცია ownership-ს იღებს
ამ ინფორმაციის ნაწილი .bki-ში ნორმალიზებული მეტადატის სახით გადადის.
ამიტომ Buk-ის კომპილატორი და Borrow Checker-ი ფლობს სრულ სემანტიკურ ინფორმაციას პროგრამის შესახებ და ამის ხარჯზე შეგვიძლია უფრო მარტივი კოდი ვწეროთ. გარდა ამისა ბუკის კომპილატორს შეუძლია ბევრად მეტი ოპტიმიზაცია გააკეთოს ამ ინფორმაციაზე დაყრდნობით.
Explicit allocation
Buk-ში მეხსიერების ალოკაციაც "ხელით" ხდება და სრულ კონტროლს გვაძლევს მეხსიერებაზე. Rust-ში ამის შესაძლებლობა არ გვაქვს და ხელით უნდა გაკეთდეს custom allocator.
მაგალითად:
let users =
List[User].Create(
capacity: 64,
in: arena,
);
in: ამბობს, სად უნდა შეიქმნას ახალი ownership და სად უნდა ჩაიწეროს ეს მნიშვნელობა. ამ შემთხვევაში გადაცემულ Arena-ში.
func Parse(
text: StringView,
in storage: Allocator,
): Document fails ParseError
{
let tokens = Tokenize(text);
return BuildDocument(tokens);
}
Caller:
let document = Parse(source, in: requestArena);
Async კოდი ფუნქციების coloring-ის გარეშე
Buk-ის კიდევ ერთი მნიშვნელოვანი მიმართულება colorless async-ია.
C#-ში, Rust-ში, Javascript-ში და სხვა ენებში async API შემდეგნაირად იწერება:
async Task<User> LoadUserAsync()
{
var data = await ReadAsync();
return Parse(data);
}
ფუნქცია სპეციალურ Task ტიპს აბრუნებს და გამოძახების მთელი ჯაჭვი უნდა იყოს async.
Buk-ში ფუნქცია სემანტიკურ მნიშვნელობას პირდაპირ აბრუნებს:
func LoadUser(id: UserId): User fails LoadError
{
let data = await database.Load(id);
return ParseUser(data);
}
ფუნქციაFuture[User], Task[User] ან Promise[User]-ს არ აბრუნებინებს.
კომპილატორი თვითონ ადგენს ფუნქციის შესრულების ტიპს (ასინქრონულია თუ სინქრონული), გამოძახების მიხედვით.
await მაინც უნდა დავწეროთ იქ სადაც ასინქრონულად გამოძახება ხდება.
თუ ფუნქცია სინქრონულად სრულდება, კომპილატორი საერთოდ გააქრობს async scheduler-ს და state machine-ს და დაბილდავს როგორც უბრალო ფუნქციას.
უფრო დეტალური ინფორმაცია ამის შესახებ გამოქვეყნდება ენის რელიზის დროს, თავის წიგნთან ერთად.
ეს იყო მოკლე აღწერა.
თავის მხრივ უფრო დეტალური აღწერა წიგნთან ერთად გამოვა რელიზის დროს, როგორც ზემოთ აღვნიშნე.
Top comments (0)