DEV Community

Rezo
Rezo

Posted on

პროგრამირების ენა Buk.

რატომ კიდევ ერთი სისტემური პროგრამირების ენა?

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();
Enter fullscreen mode Exit fullscreen mode

აქ ცვლადი document არის owner (LoadDocument-ის მიერ დაბრუნებული მნიშვნელობის ექსკლუზიური "მფლობელი").

თუ document ცვლადში არსებული მნიშვნელობა სხვა ფუნქციას მთლიანად გადავეცით, ownership-იც მასზე გადადის და ის ხდება ექსკლუზიური მფლობელი.

Publish(take document);
Enter fullscreen mode Exit fullscreen mode

ამის შემდეგ ძველი მფლობელი ამ მნიშვნელობას ვეღარ გამოიყენებს.

Borrowing ნიშნავს მნიშვნელობის დროებით გამოყენებას ownership-ის გადაცემის გარეშე, ანუ "თხოვებას".

მაგალითად:

PrintSummary(document);
Enter fullscreen mode Exit fullscreen mode

აქ ფუნქციას-ს მხოლოდ დროებითი წვდომა აქვს, ხოლო ექსკლუზიური მფლობელი ისევ document ცვლადი რჩება.

Borrow შეიძლება იყოს მხოლოდ წასაკითხად, ან exclusive mutation-ისთვის.

Buk-ში ეს სამი ოპერაცია შემდეგნაირად გამოიხატება:

Look    დროებითი read access (მხოლოდ გამოყენება/წაკითხვა)

Change  დროებითი exclusive mutation (გადავცემთ ექსკლუზიურ წვდომას რათა გამომყენებელმა შეძლოს რესურსის მნიშვნელობის შეცვლა, მისი მუდმივი ფლობის გარეშე)

Take    (ექსკლუზიური მფლობელობის სამუდამო გადაცემა)
Enter fullscreen mode Exit fullscreen mode

კომპილატორი ამ ყველაფერს კომპილაციის დროს აკონტროლებს და ამოწმებს, რომ არც 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);
Enter fullscreen mode Exit fullscreen mode

ერთი შეხედვით ჩვეულებრივი კოდია.

მაგრამ კომპილატორისთვის ამ ოთხ ხაზში სამი განსხვავებული ოპერაცია ხდება.


Look

Look ნიშნავს დროებით shared access-ს ownership-ის გადაცემის გარეშე.

func PrintSummary(document: Document)
{
    Print(document.Title);
}
Enter fullscreen mode Exit fullscreen mode

Call site:

let document = LoadDocument();

PrintSummary(document);
PrintSummary(document);
Enter fullscreen mode Exit fullscreen mode

PrintSummary(document) არ ნიშნავს, რომ Document დაკოპირდა.

არც ownership გადასულა.

ფუნქციამ უბრალოდ მიიღო დროებითი read access.
ფუნქციის დასრულების შემდეგ document ისევ გამომძახებელს ეკუთვნის.

Buk-ში ყველაზე ხშირი ოპერაცია სწორედ Look-ია, ამიტომ ის საერთოდ არ იწერება.

ანუ:

PrintSummary(document);
Enter fullscreen mode Exit fullscreen mode

უკვე ნიშნავს read-only borrow-ს.

არ გვჭირდება:

PrintSummary(look document);
Enter fullscreen mode Exit fullscreen mode

Change

Change ნიშნავს დროებით exclusive mutable access-ს.

Ownership ისევ გამომძახებელს რჩება.

func IncreaseRevision(change document: Document)
{
    document.Revision = document.Revision + 1;
}
Enter fullscreen mode Exit fullscreen mode

Call site:

IncreaseRevision(change document);
Enter fullscreen mode Exit fullscreen mode

change დაახლოებით ასე შეიძლება წავიკითხოთ:

„ეს მნიშვნელობა დროებით შენ გაბარია შესაცვლელად, მაგრამ ownership ჩემი რჩება.“

სანამ ასეთი borrow აქტიურია, კომპილატორი იმავე მნიშვნელობაზე conflicting access-ს არ დაუშვებს.

როგორც კი Change borrow-ის ბოლო რეალური გამოყენება დასრულდება, ორიგინალური მფლობელი მნიშვნელობას ჩვეულებრივად იყენებს.

IncreaseRevision(change document);

PrintSummary(document);
Enter fullscreen mode Exit fullscreen mode

აქ mutation მომენტი კარგად ჩანს როგორც ფუნქციის დეკლარაციაში, ასევე გამოძახების დროსაც.

ეს შეგნებულად ასეა.

კოდის მკითხველმა ფუნქციის გამოძახების დანახვისას უნდა იცოდეს, რომ ამ გამოძახებას document-ის შეცვლა შეუძლია.


Take

Take ownership-ის სრულ გადაცემას ნიშნავს.

func Publish(take document: Document)
{
    // ეს ფუნქცია ახლა ფლობს document-ის სრულ კონტროლს 
}
Enter fullscreen mode Exit fullscreen mode

Call site:

let document = LoadDocument();

PrintSummary(document);

Publish(take document);
Enter fullscreen mode Exit fullscreen mode

ამის შემდეგ ძველი მფლობელი ამ მნიშვნელობას (document-ს) ვეღარ გამოიყენებს:

Publish(take document);

PrintSummary(document); // compile-time error
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

აქ კომენტარები პრაქტიკულად არც არის საჭირო.

ჩვეულებრივი გამოძახება ნიშნავს "ნახვას" / "გამოყენებას".

change ნიშნავს შეცვლას.

take ნიშნავს ownership-ის გადაცემას.

კოდში პირდაპირ ჩანს სრული სემანტიკა.


Buk vs Rust

Rust-ში დაახლოებით იგივე მაგალითი ასე გამოიყურებოდა:

fn inspect(document: &Document) {}

fn normalize(document: &mut Document) {}

fn publish(document: Document) {}
Enter fullscreen mode Exit fullscreen mode

Call site:

inspect(&document);

normalize(&mut document);

publish(document);
Enter fullscreen mode Exit fullscreen mode

Buk-ში:

func Inspect(document: Document) {}

func Normalize(change document: Document) {}

func Publish(take document: Document) {}
Enter fullscreen mode Exit fullscreen mode

Call site:

Inspect(document);

Normalize(change document);

Publish(take document);
Enter fullscreen mode Exit fullscreen mode

ფუნდამენტური იდეა ორივეგან მსგავსია: ownership, shared borrow და exclusive mutable borrow.

განსხვავება უფრო რეალიზაციის მოდელშია.

Rust-ი კლასიკურ pointer/reference სემანტიკას იყენებს.

&T
&mut T
Enter fullscreen mode Exit fullscreen mode

Buk-ი კი პროგრამისტის განზრახვას ძირითადად ოპერაციის დონეზე აჩვენებს:

value
change value
take value
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

Buk-ს public lifetime syntax საერთოდ არ აქვს.

არ გვაქვს:

'a
'b
'static
Enter fullscreen mode Exit fullscreen mode

კომპილატორი თვითონ ადგენს, საიდან მოდის მნიშვნელობა და რაზეა მისი lifetime დამოკიდებული.

მაგალითად:

func Name(user: User): StringView
{
    return user.Name.AsView();
}
Enter fullscreen mode Exit fullscreen mode

კომპილატორს შეუძლია დაადგინოს, რომ დაბრუნებული StringView user-ის backing storage-ზეა დამოკიდებული.

პროგრამისტს ამის ხელით აღწერა არ სჭირდება.


მთავარი კომპილატორის მაგია. საიდან მოაქვს Buk-ს ინფორმაცია Lifetime-ების შესახებ, თუ კი კოდში არ ვწერთ "'"-ებს ?

აქ ჩნდება .bki, ანუ Buk Package Interface.

.bki არის კომპლექსური მეტადატის შემცველი ფაილი, რომელსაც კომპილატორი აგენერირებს.

მასში ინახება კოდის სემანტიკური კონტრაქტები.

მაგალითად კომპილატორს შეიძლება დასჭირდეს ცოდნა იმის შესახებ, რომ:

დაბრუნებული მნიშვნელობის შენახვა ხდება რომელიღაც backing storage-ში (მაგალითად კოლექცია "UserList"-ის მეხსიერებაში)

ფუნქცია მხოლოდ კონკრეტულ ველს ცვლის

მნიშვნელობა await-ის იქით ვერ გადავა 

ფუნქცია ownership-ს იღებს
Enter fullscreen mode Exit fullscreen mode

ამ ინფორმაციის ნაწილი .bki-ში ნორმალიზებული მეტადატის სახით გადადის.

ამიტომ Buk-ის კომპილატორი და Borrow Checker-ი ფლობს სრულ სემანტიკურ ინფორმაციას პროგრამის შესახებ და ამის ხარჯზე შეგვიძლია უფრო მარტივი კოდი ვწეროთ. გარდა ამისა ბუკის კომპილატორს შეუძლია ბევრად მეტი ოპტიმიზაცია გააკეთოს ამ ინფორმაციაზე დაყრდნობით.


Explicit allocation

Buk-ში მეხსიერების ალოკაციაც "ხელით" ხდება და სრულ კონტროლს გვაძლევს მეხსიერებაზე. Rust-ში ამის შესაძლებლობა არ გვაქვს და ხელით უნდა გაკეთდეს custom allocator.

მაგალითად:

let users =
    List[User].Create(
        capacity: 64,
        in: arena,
    );
Enter fullscreen mode Exit fullscreen mode

in: ამბობს, სად უნდა შეიქმნას ახალი ownership და სად უნდა ჩაიწეროს ეს მნიშვნელობა. ამ შემთხვევაში გადაცემულ Arena-ში.

func Parse(
    text: StringView,
    in storage: Allocator,
): Document fails ParseError
{
    let tokens = Tokenize(text);

    return BuildDocument(tokens);
}
Enter fullscreen mode Exit fullscreen mode

Caller:

let document = Parse(source, in: requestArena);
Enter fullscreen mode Exit fullscreen mode

Async კოდი ფუნქციების coloring-ის გარეშე

Buk-ის კიდევ ერთი მნიშვნელოვანი მიმართულება colorless async-ია.

C#-ში, Rust-ში, Javascript-ში და სხვა ენებში async API შემდეგნაირად იწერება:

async Task<User> LoadUserAsync()
{
    var data = await ReadAsync();
    return Parse(data);
}
Enter fullscreen mode Exit fullscreen mode

ფუნქცია სპეციალურ Task ტიპს აბრუნებს და გამოძახების მთელი ჯაჭვი უნდა იყოს async.

Buk-ში ფუნქცია სემანტიკურ მნიშვნელობას პირდაპირ აბრუნებს:

func LoadUser(id: UserId): User fails LoadError
{
    let data = await database.Load(id);
    return ParseUser(data);
}
Enter fullscreen mode Exit fullscreen mode

ფუნქციაFuture[User], Task[User] ან Promise[User]-ს არ აბრუნებინებს.

კომპილატორი თვითონ ადგენს ფუნქციის შესრულების ტიპს (ასინქრონულია თუ სინქრონული), გამოძახების მიხედვით.

await მაინც უნდა დავწეროთ იქ სადაც ასინქრონულად გამოძახება ხდება.

თუ ფუნქცია სინქრონულად სრულდება, კომპილატორი საერთოდ გააქრობს async scheduler-ს და state machine-ს და დაბილდავს როგორც უბრალო ფუნქციას.

უფრო დეტალური ინფორმაცია ამის შესახებ გამოქვეყნდება ენის რელიზის დროს, თავის წიგნთან ერთად.

ეს იყო მოკლე აღწერა.
თავის მხრივ უფრო დეტალური აღწერა წიგნთან ერთად გამოვა რელიზის დროს, როგორც ზემოთ აღვნიშნე.

Top comments (0)