Порівняння перевірки запозичень Rust із аналогом у C#
Це переклад орігінальної статті із англійської. Усі дяки туди!
Хвилинку! C# має засіб перевірки запозичень?
Дивіться: класичний приклад безкоштовної безпеки пам’яті у Rust…
// error[E0597]: `shortlived` does not live long enough
let longlived = 12;
let mut plonglived = &longlived;
{
let shortlived = 13;
plonglived = &shortlived;
}
*plonglived;
…портуючи на C#:
// error CS8374: Cannot ref-assign 'shortlived' to 'plonglived' because
// 'shortlived' has a narrower escape scope than 'plonglived'
var longlived = 12;
ref var plonglived = ref longlived;
{
var shortlived = 13;
plonglived = ref shortlived;
}
_ = plonglived;
Гаразд, C# не поділяє концепцію «запозичення» із Rust, тому технічно було б неправильно називати це «перевіркою запозичень», але на практиці коли люди говорять про «перевірку запозичень Rust», вони говорять про весь статичний аналіз, який Rust робить для забезпечення безпеки пам’яті, тому C#, на мою думку, відповідає цим вимогам.
Коли я вперше побачив цю фічу в C# (а також Span
-и, ref struct
-и та stackalloc
), я був вражений: де всі кутові дужки та апострофи? Як це можливо, щоб я міг писати ефективний і перевірено-безпечний код на C# без ступеня з теорії типів? У цьому документі я сподіваюся коротко підсумувати своє розуміння безпеки пам’яті в C#, провести пряме порівняння між конструкціями C# і відповідними конструкціями Rust-у і, можливо, пролити світло на те, які саме компроміси зробив C#, щоб зробити це настільки зручним для користувача.
Коротка історія безпеки посилань1 C#
З самого початку (2000-і) у C# було ключове слово ref
для параметрів, що передаються у функцію за посиланням, але це було майже все, що ви могли з ним зробити. Якщо ви хочете робити ефективні речі з виділеною на стеку пам’яттю та опосередкованою адресацією2, ви би зазвичай використовуєте «unsafe» частини мови або робили виклики до C++. Лише у 2017 році із випуском C# версії 7 ми почали бачити як ця фіча стала узагальнюватися у щось більш корисне. Із того часу C# додав:
ref
локальні змінніref
результати- безпечні ініціалізатори
stackalloc
readonly struct
таref struct
in
параметри (і пізнішеref readonly
параметри)- умовні
ref
вирази - розширення до
stackalloc
ref
поля
У процесі додавання вищезазначених фіч C#-у потрібно було визначити правила щодо використання ref, які й надалі забезпечуватимуть безпеку пам’яті. Специфікація мови називає ці правила «безпечними контекстами посиланнь»3 (див. тут і тут). «Безпечний контекст посилання», швидше за все, більш відомий програмістам Rust як термінів життя 4, область вихідного тексту у якому дійсний доступ/використання посилання.
Порівняння безпечних контекстів і термінів життя
Як і в Rust, у C# неможливо явно оголосити час життя значення. На відміну від Rust, у C# також неможливо призначити ім’я тривалості життя за допомогою узагальнених параметрів типу. В обох мовах правильне використання функції має бути зрозумілим лише за її оголошенням і не потребує аналізу її тіла. У Rust це означає, що терміни життя повинні з’явитися в декларації функції:
// V name the lifetime using generic type parameter
// V --------- V reference named lifetime in parameter and return types
fn return_reference<'a>(r: &'a i32) -> &'a i32 {
r // <-- compiler makes sure we return what we claim to in the signature
}
C# не має окремого синтаксису для цього. Тим не менш, еквівалентний код все ще компілюється:
// Нема термінів життя!
ref int ReturnReference(ref int r){
return ref r;
}
Компілятор C# просто припускає, що термін життя повертаємого значення такий самий, як і термін життя параметра. Rust може зробити те саме…
// Нема термінів життя!
fn return_reference(r: &i32) -> &i32 {
r
}
…і називає цю фічу уникнення терміну життя5. У Rust уникнення терміну життя є необов’язковим, і програміст завжди може явно вказати час життя всіх посилань. У C#, навпаки, компілятор повинен визначити час життя для всіх оголошень функцій. Наприклад, така функція Rust повертає лише один із двох своїх аргументів:
// V---V Two lifetimes for our two parameters
// V------------------------V Return lifetime is the same as that of
// the first parameter
// V Second parameter lifetime is unused
fn return_reference<'a, 'b>(r: &'a i32, r2: &'b i32) -> &'a i32{
r // <-- compiler would not allow us to return r2 here
}
Уникнення терміну життя не реалізовано для функцій цієї форми, оскільки, здається, немає розумного значення за умовчанням для вибору. Тим не менш, C# повинен вибрати один:
// Нема термінів життя! Зачекайте. А що вони є?
ref int ReturnReference(ref int r, ref int r2){
return ref r;
}
Оскільки не має сенсу «вибирати» або r
, або r2
для терміну життя повертаємого значення, C# консервативно припускає, що повернення може бути будь-яким із них. Таким чином, припускається, що і аргументи, і повертаємого значення мають однаковий час життя, який у специфікації називається «контекст виклику»6. Еквівалентна функція Rust виглядала б так:
// V only one lifetime, called "caller-context"
fn return_reference<'cc>(r: &'cc i32, r2: &'cc i32) -> &'cc i32{
r // <-- compiler allows us to return either r or r2
}
Це є менш корисним, ніж оригінальна функція Rust. Наприклад, наступний код буде успішно скомпільовано із першим оголошенням, але не з другим:
fn wrapper(r: &i32) -> &i32{
let i = 12;
return_reference(r, &i) // error[E0515]: cannot return value referencing local variable `i`
}
і в C#:
ref int Wrapper(ref int r){
var i = 12;
// Cannot use a result of 'Program.ReturnReference(ref int, ref int)'
// in this context because it may expose variables referenced by
// parameter 'r2' outside of their declaration scope
return ref ReturnReference(ref r, ref i);
}
Тут ми бачимо перший компроміс C#: терміни життя менш явні, але також менш потужні. Усталення також можуть бути неінтуїтивними: скажімо, ми хочемо написати метод на структурі, який повертає посилання на одного з членів структури. У Rust це просто:
struct Foo {
member: i32
}
impl Foo {
fn get_member<'a, 'b>(&'a self, unused: &'b i32) -> &'a i32 {
&self.member // <-- compiler would not allow us to return `unused`
}
}
Насправді це настільки поширене явище, що Rust не вимагає від вас явного запису темріну життя, знову ж таки завдяки «уникненню терміну життя»:
struct Foo {
member: i32
}
impl Foo {
fn get_member(&self, unused: &i32) -> &i32 {
&self.member // <-- would still not be allowed to return `unused`
}
}
Однак еквівалентний код C# не компілюється:
struct Foo {
int member;
ref int GetMember(ref int unused){
// error CS8170: Struct members cannot return 'this' or
// other instance members by reference
return ref this.member;
}
}
Це тому, що усталення C# є протилежністю Rust: метод struct, який повертається за посиланням, може повертати будь-яке посилання, інакше ніж неявне посилання this
. Приклад нижче компілюється:
struct Foo {
int member;
ref int GetMember(ref int unused){
return ref unused;
}
}
Історія C# сягає корінням у стиль програмування OOP/Java, і якщо дозволити методам повертати посилання this
, ви не зможете писати такий код:
ref int DoAThing(ref int p){
// This reference is safe to return because it
// could only be referencing p
return ref new Foo().DoWhatever(ref p);
}
Відсутність явних анотацій терміну життя означає, що C# має вибирати, які шаблони дозволені, а які ні.
Аварійний вихід: збір сміття
Припустімо, ми хочемо написати функцію, яка повертає посилання на ціле число в буфері, якщо воно його знаходить:
ref int Find(Span<int> haystack, int needle){
for(var i = 0; i < haystack.Length; i++)
if(haystack[i] == needle)
return ref haystack[i];
throw new Exception("Not Found");
}
Замість того, щоб створювати виключення, ми вирішили, що ця функція має завжди повертати щось, навіть якщо це не в haystack. Але нам більше нема чого повертати! Наступний код не компілюється:
ref int Find(Span<int> haystack, int needle){
for(var i = 0; i < haystack.Length; i++)
if(haystack[i] == needle)
return ref haystack[i];
var def = 0;
return ref def; // Cannot return local 'def' by reference because it is not a ref local
}
Природно, що все що оголошене у Find
, випаде з області видимості, коли Find
повернеться, і тому не може бути повернуто за посиланням. Однак C# має надздібність. Ми можемо написати наступне:
ref int Find(Span<int> haystack, int needle){
for(var i = 0; i < haystack.Length; i++)
if(haystack[i] == needle)
return ref haystack[i];
var def = new int[1];
return ref def[0];
}
Масив, на який посилається def
, і значення, що повертається функцією, будуть існувати, поки на нього є посилання. Rust не має еквівалента цьому. Найближче, що ви можете отримати (я вважаю), це щось на зразок наступного:
fn find(haystack: &[i32], needle: i32) -> Cow<i32> {
for item in haystack {
if *item == needle {
return Borrowed(item);
}
}
Owned(0)
}
Це не прозоро для викликача функції. Якби ми хотіли мати витік пам’яті, ми могли б написати наступне:
fn find(haystack: &[i32], needle: i32) -> &i32 {
for item in haystack {
if *item == needle {
return item;
}
}
Box::leak(Box::new(0))
}
Box::leak
повертає посилання, яке перетворюється на &'static i32
, де 'static
представляє термін життя програми (тобто «назавжди»). Із 'static
терміном життя найлегше мати справу, оскільки його можна сконвертувати у будь-який інший термін життя. У C# збирач сміття існує, щоб робити посилання триваючими вічно, і тому кожне посилання на купу в C# можна вважати еквівалентним до Rust 'static
.
Ігноруючи наслідки продуктивності, це здається однозначно гарною річчю: 'static
може показувати будь-куди, тому те, що всі посилання на купу є 'static
гарантує максимальну гнучкість. На жаль, ні:
Action CreateCounter(ref int i){
return () => {
// Cannot use ref, out, or in parameter 'i' inside
// an anonymous method, lambda expression, query
// expression, or local function
i += 1;
};
}
Оскільки посилання на купу можуть існувати вічно, розміщувати ref
у купі заборонено. Це означає, що ref
не можна використовувати в лямбда-захопленнях або змінних членів класу/структури. Натомість мова надає ref struct
, свого роду структуру, яка може містити ref
и, але також зобов’язана ніколи не потрапляти в купу.
So: garbage collection lets C# do things safely that are impossible to do in Rust, but splits the language into the “garbage collected” and “stack allocated” worlds. Rust has a stack/heap distinction, but doesn’t need the concept of a “stack-only” or “heap-only” type. Отже: збирання сміття дозволяє C# безпечно робити те, що неможливо зробити в Rust, але розділяє мову на світи «можливо збирати сміття» та «виділено на стеку». Rust має різницю між стеком і купою, але не потребує концепції типу «лише стек» або «лише купа».
Спільне використання XOR мутації
У Rust кожне посилання або:
- Спільне: може існувати кілька посилань і з них можна читати, але в жодне не можна писати
- Ексклюзивне: посилання можна зчитувати або записувати, але дозволено існувати лише одне незапозичене посилання
Це обмеження є центральним для гарантій безпеки Rust, але C# його не потребує. Причина полягає в тому, що Rust має враховувати можливість того, що посилання може стати недійсним у будь-який час. Наприклад:
let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4);
// r could be invalid now
Навпаки, у C# посилання на купу ніколи не є недійсними, тоді як ref
и можуть бути недійсними лише після виходу з блоку:
var v = new List<int>{1, 2, 3};
var sp = CollectionsMarshal.AsSpan(v);
ref var r = ref sp[0];
v.Add(4);
// r is definitely still valid (kinda)
Щоб гарантувати правильність, перевірка запозичень Rust має заборонити будь-які операції, які можуть зробити посилання недійсним, доки воно використовується. Усе, що потрібно зробити C#, це переконатися, що посилання
на дані, виділені стеком, ніколи не виходить за межі області, у якій воно було створено.
Чому, здається, ніхто про це не говорить?
Можливо, я погано шукаю ці речі, але ці зміни в C#, здається, повністю пройшли поза увагою в місцях, де ви читали про безпеку пам’яті та продуктивність. Можливо, це просто тому, що додавання мов відбувається дуже повільно, або, можливо, спільноти C# і Rust настільки мало збігаються, що недостатньо людей, які програмують обома мовами, щоб помітити подібність. Можливо, є щось, що робить ref
підмножину C# настільки непридатною для використання, що люди її просто ігнорують (я, визнаю, поки що лише трохи погрався з нею).
Ось моя теорія: C# вже мав еквівалент усіх цих речей у своїй «небезпечній» підмножині, тому, коли вводилися, зміни ref
-безпеки зазвичай були оформлені як «наближення продуктивності безпечного коду до продуктивності небезпечного». що, мабуть, є протилежною до точки зору Rust «наближення безпеки високопродуктивного коду до безпеки мов високого рівня». Можливо, така постановка питання змушує людей випустити із уваги те, що хоча дві мови рухаються в протилежних напрямках, вони насправді можуть зближуватися одна до одної.
-
Безпека посилань, це у англомовній літературі
ref safety
↩ -
Опосередкованою адресацією я називаю тут
indirection
↩ -
Безпечні контексти посиланнь у англомовній літературі
ref safe contexts
↩ -
Термін життя, тривалості життя, хоч і незвично це
lifetime
↩ -
Уникнення терміну життя це
lifetime elision
↩ -
Контекст виклику у англомовній документації називається
caller-context
↩