Це переклад орігінальної статті із англійської. Усі дяки туди!

Хвилинку! 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 «наближення безпеки високопродуктивного коду до безпеки мов високого рівня». Можливо, така постановка питання змушує людей випустити із уваги те, що хоча дві мови рухаються в протилежних напрямках, вони насправді можуть зближуватися одна до одної.

  1. Безпека посилань, це у англомовній літературі ref safety 

  2. Опосередкованою адресацією я називаю тут indirection 

  3. Безпечні контексти посиланнь у англомовній літературі ref safe contexts 

  4. Термін життя, тривалості життя, хоч і незвично це lifetime 

  5. Уникнення терміну життя це lifetime elision 

  6. Контекст виклику у англомовній документації називається caller-context