Постановка задачи


Код на языке C# нужно перевести в код на Rust. Точнее, требуется такая процедура перевода (разработка продолжается на C#), чтобы в любой момент можно было получить работающий код на Rust. Эту задачу я решал для языков Java, Python, JavaScript и PHP, написав конвертер из C# в эти языки. Концепция такого конвертирования была изложена в статье UniSharping пару лет назад. Я разрабатывал этот конвертер, чтобы переводить код своего проекта SDK Pullenti (лингвистический анализ текста). И подумалось мне: а не замахнуться ли на Rust? Да, слышал разные отзывы, что язык необычный и пр., но попытка же не пытка… Тем более, что у одного из заказчиков группа программистов увлечённо пишет на нём.


Сразу скажу, что в полном объёме, как для других языков, этого сделать не получилось — не хватило сил. Может, ещё вернусь к этой задаче. Полтора месяца было потрачено на борьбу с собой и языком, удалось довести конвертер до состояния, что морфологический блок начал переводиться и даже компилироваться (значит, и работать) на Rust-е. Разумеется, за это время модуль морфологии можно было написать с нуля, но за ним маячили ещё около 500 классов C#, создаваемые и отлаживаемые почти 10 лет, а их переписать не так просто. В этой статье хочу поделиться своими впечатлениями от языка Rust, а также описать приёмы, которые я использовал для конвертирования.


Впечатление от языка Rust


Говорят, что мастер не ищет лёгкий путей. Это в полной мере относится к Rust, так как многое простое и привычное на других языках становится сложным, при этом сложное не становится простым. Вы как бы попадаете в другой мир с абсурдной на первый взгляд логикой, которая становится далеко не сразу понятна после освоения базовых концепций. Неважно, на чём вы писали до сих пор: Си++, Java, Python и пр., но когда оказывается, что после добавления в список объект нельзя использовать: it = new ...(); list.add(it); it.val = ..., а вот так можно: it = new ...(); it.val = ...; list.add(it);, то это обескураживает. Или чтобы реализовать перекрёстные ссылки между объектами класса Foo, нужно использовать конструкцию Option<Rc<RefCell<Foo>>>, а для доступа к полю val этого класса вызывать foo.unwrap().borrow().val.


Выскажу своё личное мнение: мне представляется, что прогресс в области программирования идёт в направлении оптимизации труда программиста, чтобы меньшими усилиями достигать большего эффекта. Случай Rust уникален тем, что не вписывается в эту тенденцию. Удивительным для меня фактом является рост его популярности (сейчас Rust вошёл в 20-ку). Но почему? Вот вопрос.


По производительности на моей задаче Rust не произвёл впечатления — выигрыш по сравнению с C# получился всего в 2 раза. Подчеркну, что это на моей частной задаче морфологического анализа, которую удалось перевести в эквивалентный код (наверняка человек написал бы оптимальнее). В разных статьях сравнения производительности приводятся разные данные, и в целом создаётся впечатление, что Rust и C/C++ близки по скорости. Но существенно разнятся по сложности написания кода. Утверждается, что Rust сильно уменьшает вероятность утечек памяти по сравнению с С/C++, доступа за границу массива и прочее, но какой ценой...


Единственным разумным для меня объяснением роста популярности Rust является то, что Си "поднадоел" за почти 50 лет, и молодое поколение программистов желает чего-то нового, не обязательно лучшего. Как молодёжь 80-х добровольно ехала из обжитых городов строить БАМ (такая железная дорога на Дальнем Востоке), стойко перенося трудности и лишения, застревая потом в таёжных городках и посёлках. Подобно и здесь. Си-шника я ещё могу понять, так как он получает trait-ы (типа interface в Java и C#), на которых можно худо-бедно реализовывать ООП, и ещё некоторые полезные штучки. Но вот что здесь искать программистам других языков, кроме романтики новой экосистемы, в создании которой можно поучаствовать? Мне показалось, что при переходе на Rust большее теряешь, чем находишь.


Работа с кучей


Базовое отличие Rust от других языков — в парадигме работы с кучей (heap). В древних языках всё просто — выделение и освобождение памяти в куче происходит явно операторами типа new/delete. Это С\С++, Паскаль, Фортран и др. Возникает утечка памяти, если delete не вызвать. Потом решили упростить пр��граммистам жизнь, избавив от необходимости явно освобождать память. Этот процесс перенесли на уровень так называемых сборщиков мусора, которые сами заботятся об освобождении в нужный момент, программист же только выделяет через new. Это имеет место во всех известных мне современных языках: Java, C#, Python, JavaScritp, PHP и пр.


В Rust освобождение памяти происходит тоже автоматически, но сразу при окончании жизни объекта, например, когда он выходит из области видимости. Скажем, если внутри блока создать объект { ... let x = Foo {...это конструктор}; ... }, то память автоматически освободится при выходе управления из этого блока. И вот весь геморрой — из-за этой парадигмы. Приходится вводить понятие владельца объекта, ссылки на владельца, изменяемой (mut) ссылки на владельца, время жизни и другие понятия, порождающие ограничения языка. Скажем, изменяемая ссылка может быть только одна, и вот аналог C# чтения в буфер buf из потока stream.Read(buf, 0, buf.Length) не будет компилироваться, поскольку в первом аргументе buf перезаписывается и должен быть mut-ссылкой, поэтому в третьем аргументе уже никак этот buf использовать нельзя. А вот так можно: int len = buf.Length; stream.Read(buf, 0, len);.


Далее опишу решения, которые я использовал в конвертере для перевода кода C# в Rust. Напомню, что именно это и было исходной задачей — конвертирование существующего кода.


Ограничения кода C#


Несколько лет назад я озаботился тем, чтобы перевести свой SDK на C# в язык Java. Опробованные конвертеры не подошли, так как выдавали на выходе грязный код, который ещё править и править. Да, они заточены на задачу разовой миграции, а мне хотелось иного — продолжать разработку на C# и получать на выходе сразу исполняемый код, работающий эквивалентно. Пришлось писать самому. Этому была посвящена статья UniSharping. Если кратко, то в общем случае невозможно решить эту задачу. Однако, если придерживаться некоторых ограничений при написании кода C#, то невозможное становится возможным. Например, в Java отсутствует аналог оператора yield, так давайте избавимся от него в исходном коде C# — невелика потеря!
С каждым новым поддержанным языком исходный код на C# слегка корректировался. Для Java пришлось отказаться от плагинной техники динамической загрузки DLL, поскольку в Java и других языках понятие сборки просто отсутствует. Для Python пришлось убрать одинаковые названия методов в классе, поскольку сигнатура Питона включает только имя, а типы аргументов у него не указываются. У JavaScript обнаружилось отсутствие типа long (есть byte, short, int, float, double, а вот на long-е разработчики сэкономили), пришлось мне в коде SDK C# заменить все long на int, благо их оказалось немного. В PHP ждала засада в виде представления string как последовательность байт кодировки utf-8 с невозможностью быстрого доступа к i-му символу строки без перебора. Тут я уже ничего у себя переделывать не стал, а использовал их штатные функции mb_, из-за чего производительность получилась чудовищно низкой. В Rust со строками такая же ситуация, но тут я поступил по-другому.


Также плодотворной оказалась идея использования директив препроцессора с коде C#, когда нужно в зависимости от языка слегка что-нибудь подправить: #if JAVA || PYTHON… #else… #endif — конвертер понимает такие конструкции.


Огромная сложность автоматического перевода — это библиотечные классы и методы. Хорошо, когда есть полный аналог в другом языке, а если нет? Тогда приходится реализовывать на конечном языке эту функциональность и добавлять её в файлы генерируемого кода. Для Rust это пришлось делать более, чем для других языков.


Итак, приступим.


Стандартные конструкции


Выражения C#, операторы ветвления, циклы, функции — для всего этого Rust имеет эквиваленты, тут всё просто. Только циклы for(...; ...; ...) в большинстве случаев приходится разворачивать в while — к этому я был хорошо подготовлен Питоном. С простыми типами byte, int, float и пр. тоже просто, на то они и простые. А вот с непростыми сложнее.


Для непростого типа T из C# в Rust следует использовать три разных типа: T (для владельца объекта), &T (неизменяемая ссылка) и &mut T (изменяемая ссылка). Можно считать, что в C# произошло как бы слияние в один тип трёх разных типов — с C# он один, а в Rust это три типа, причём в нужных местах этот тип должен быть одним из этих трёх.


var obj = new T(); // создали объект класса T
FuncNotModif(obj); // передали аргументом в функцию
FuncModif(obj); // здесь объект модифицируется
list.Add(obj); // добавили в список List<T>
var obj2 = obj; // другая ссылка на тот же объект
var obj3 = obj; // другая ссылка на тот же объект

Вот как этот фрагмент можно представить в Rust:


let obj = T { }; // создали объект класса T (о классах чуть ниже)
func_not_modif(&obj); // передаём неизменяемую ссылку, иначе дальше obj нельзя будет использовать
func_modif(&mut obj); // а здесь модифицирующая ссылка
list.push(&obj); // можно добавлять только в вектор ссылок Vec<&T>, иначе потом obj недоступен
let obj2 : &T = &obj; // другая ссылка на объект
let obj3 : T = obj; // а здесь владение переходит к obj3, и obj с obj2 больше нельзя использовать

Итак, принципиальным моментом в Rust является владение экземпляром, которое переходит при присваивании значения: в операторе =, return и передаче аргументом без указания &. После этого предыдущая переменная и все ссылки становятся недействительными, эстафета как бы передаётся другой переменной.


А как в коде C# понять в каждом случае, какую из этих трёх разновидностей использовать: T, &T или &mut T? Хороший вопрос, и я его решил так:


По умолчанию аргументы методов являются &T или &mut T в зависимости от того, модифицируются ли они или нет в самом методе (это автоматом определяет конвертер), у методов, реализующих property { get; set; } возвращаемые значения &T, всё остальное — T. В коде C# сразу за типом можно указать через комментарий /*&*/ или /*&mut*/ нужный вариант. Например, для списка ссылок подсказка конвертеру будет List<T/*&*/>, а если и сам список является ссылкой, то List<T/*&*/>/*&*/.


Здесь некоторые из дочитавших до этого места разочарованно произнесут: мы то думали, что конвертер сам понимает, а ему приходится указывать, ещё и коверкать исходный код нелепыми вставками. Согласен, после разоблачения фокус воспринимается уже не так. Но это — решение, и лучше мне не удалось найти. К тому же оказалось, что в моём случае морфологического блока таких вставок получилось не так уж много.


Строки


Строки в Rust представляются последовательностью байт кодировки utf-8 (как и в PHP). Думаю, здесь 2 причины. В C#, Java и др. строки есть последовательность символов char размером 16 бит (в Си 8 бит, в Си++ 8 и 16), что есть ограничение с точки зрения разработчиков Rust. Сейчас Unicode уже 32-битный, а вдруг в будущем он вообще станет 64-битным? А они будут к этому готовы. Другая причина субъективная — основатель англоязычный, и его слабо волнуют проблемы за пределами 7-битной ASCII.


А в моей переводимой библиотеке идёт интенсивная работа со строками и доступом к её элементам str[i]. Как быть?


Решение — реализовать класс (struct в терминологии Rust), содержащий как вектор символов, так и сам string.


#[derive(Clone)]
pub struct NString {
    pub chars : Vec<char>,
    pub string : String,
    _is_null : bool
}
impl NString {
    pub fn from_string(s : &String) -> NString {
        NString { chars : s.chars().collect(), string : s.clone(), _is_null : false }
    }
    pub fn from_str(s : &str) -> NString {
        NString { chars : s.chars().collect(), string : s.to_string(), _is_null : false }
    }
    pub fn from_chars(s : &Vec<char>) -> NString {
        NString { chars : s.clone(), string : s.into_iter().collect(), _is_null : false }
    }
...
}

Когда нужно работать как массивом элементов, то используется поле chars, иначе — штатный String. Для этой структуры реализованы различные стандартные методы C# работы со строками, если аналогов не находилось в Rust. Например, вот метод Substring(int start, int len) для получения подстроки:


    pub fn substring(&self, pos : i32, len : i32) -> NString {
        let length : i32 = if len <= 0 { self.chars.len() as i32 - pos } else { len };
        let sub = self.chars[pos as usize .. (pos + length) as usize].to_vec();
        NString::from_chars(&sub)
    }

А строки-лексемы конвертер представляет так, ссылаясь в коде &STR_HELLO для ссылки или STR_HELLO.clone() для владения:


static STR_HELLO : Lazy<NString> = Lazy::new(|| { NString::from_str("Hello!") }); 
use once_cell::sync::Lazy;

Коллекции


Разумеется, в Rust есть множество типов для работы с коллекциями, но они по ряду причин не подошли. Если сразу писать на Расте, то может и ничего, но транслировать из кода C# оказалось затруднительно, поэтому пришлось как и для строк написать обёртки над Vec и HashMap и использовать их. Причём получилось 3 обёртки для каждого типа в зависимости от типа элементов: для простых типов, для ссылок &T и для владений T. Массивы array[] я транслировал в Rust так же, как и List.

Object


В Rust нет привычных всем null и базового класса object, практически отсутствует и приведение типов. То, что в C# любой тип можно "упаковать" в object и затем "распаковать" его — для Rust это за пределами возможного. Я не придумал лучшего решения, чем следующее.


Если в существующем коде используется object, то как правило в реальности в конкретном месте фигурирует ограниченный набор типов значений для этого object. Поэтому можно создать служебный класс, содержащий в отдельных полях значения этих типов, и использовать его, указав конвертеру в виде подсказки /*=имя*/ после object.


            object/*=ObjValue*/ obj = "Hello";
            Console.WriteLine(obj);
            obj = 10;
            if (obj is int)
            {
                int ii = (int)obj;
                Console.WriteLine(ii);
            }
            obj = cnt.First; // объект класса Item
            if(obj is Item)
                Console.WriteLine((obj as Item).Str);

#if RUST  // компилятор C# игнорирует этот фрагмент
        //RUST object_class
        class ObjValue
        {
            public string Str;
            public int Int;
            public Item/*&*/ Item;
        }
#endif

Здесь мы знаем, что object принимает значения только int, string и Item, причём это именно ссылка, а не владение Item — им владеют в другом месте.


Создаём класс ObjValue, который игнорируется компилятором C#, но который воспринимается конвертером.


        let mut obj : ObjValue = ObjValue::from_str_(STR_HELLO.clone());
        println!("{}", &obj.to_nstring());
        obj = ObjValue::from_int(10);
        if obj.is_class("i32") {
            let mut ii : i32 = obj.int;
            println!("{}", &NString::from_string(&ii.to_string()));
        }
        obj = ObjValue::from_item(Some(Rc::clone(cnt.borrow().get_first().as_ref().unwrap())));
        if obj.is_class("Item") {
            println!("{}", obj.item.as_ref().unwrap().borrow().get_str());
        }

pub struct ObjValue {
    pub str_ : NString, 
    pub int : i32, 
    pub item : Option<Rc<RefCell<dyn IItem>>>, 
    _typ : &'static str
}

impl ObjValue {
    pub fn from_str_(val : NString) -> ObjValue {
        ObjValue { str_ : val, int : 0, item : None, _typ : "NString" }
    }
    pub fn from_int(val : i32) -> ObjValue {
        ObjValue { str_ : NString::null(), int : val, item : None, _typ : "i32" }
    }
    pub fn from_item(val : Option<Rc<RefCell<dyn IItem>>>) -> ObjValue {
        ObjValue { str_ : NString::null(), int : 0, item : val, _typ : "Item" }
    }
    pub fn null() -> ObjValue {
        ObjValue { str_ : NString::null(), int : 0, item : None, _typ : "" }
    }
    pub fn is_null(&self) -> bool { self._typ.len() == 0 }
    pub fn is_class(&self, typ : &str) -> bool { self._typ == typ }
    pub fn to_nstring(&self) -> NString {
        if self._typ == "NString" { return self.str_.clone(); }
        if self._typ == "i32" { return NString::from_string(&self.int.to_string()); }
        if self._typ == "Item" { return NString::from_str("Option<Rc<RefCell<dyn IItem>>>"); }
        NString::null()
    }
}

Да, громоздко. Но ведь это же конвертер генерирует! Главное, что работает.
Обратим внимание: для шарпового obj = cnt.First на Rust получается obj = ObjValue::from_item(Some(Rc::clone(cnt.borrow().get_first().as_ref().unwrap()))). Что говорите, это жесть? Нет, это Раст! Разумеется, человек напишет короче, здесь лишь попытка дать универсальное решение доступа к члену класса.


Классы и наследование


Аналогом класса C# в Rust выступает struct, аналогом интерфейса — trait. Трейты множественно наследуются, структуры — вообще не наследуются. Структура может реализовывать любое число трейтов. То есть как бы в C# убрали наследование от другого класса, но оставили интерфейсы: из ООП полноценной осталась только инкапсуляция. Ну и на том спасибо.


Есть какой-то хитрый способ через виртуальные таблицы как-то моделировать наследование и полиморфизм, разобраться в котором мне попросту не хватило мозгов.


Я придумал следующий способ. Для класса A, от которого идёт наследование, всегда генерируется один trait, в который переносятся все методы, а для публичных полей генерируется функции get и set (как для property). В struct наследного класса B добавляется экземпляр класса A (struct B { base : A, другие поля }), и этот B также реализует этот trait от A. Причём если нужен доступ к полю или методу A, то используется self.base.x.
Приведу пример из кода реализации двунаправленного списка.


    //RUST RefCell
    class Item
    {
        public Item(int val) { Val = val; }
        public int Val { get; set; }
        public string Str;
        public Item/*&*/ Prev { get; set; }
        public Item/*&*/ Next { get; set; }
        public virtual void Inc() { Val += 1; }
    }
    //RUST RefCell
    class ItemChild : Item
    {
        public ItemChild(int val) : base(val) { }
        public override void Inc() { Val *= 2; }
    }

Вот работа конвертера (некоторые фрагменты будут удалены для краткости). Это сгенерированный базовый trait.


pub trait IItem {
    fn get_val(&self) -> i32;
    fn set_val(&mut self, value : i32) -> i32;
    fn get_str(&self) -> &NString;
    fn set_str(&mut self, value : NString) -> &NString;
    fn get_prev(&self) -> &Option<Rc<RefCell<dyn IItem>>>;
    fn set_prev(&mut self, value : Option<Rc<RefCell<dyn IItem>>>) -> &Option<Rc<RefCell<dyn IItem>>>;
    fn get_next(&self) -> &Option<Rc<RefCell<dyn IItem>>>;
    fn set_next(&mut self, value : Option<Rc<RefCell<dyn IItem>>>) -> &Option<Rc<RefCell<dyn IItem>>>;
    fn inc(&mut self);
    fn get_base_class(&self) -> &dyn IItem;
    fn is_class(&self, name : &str) -> bool;
    fn as_item(&self) -> &dyn IItem;
    fn as_mut_item(&mut self) -> &mut dyn IItem;
}

Это реализация базового класса.


pub struct Item {
    pub _val : i32, 
    pub m_str : NString, 
    pub _prev : Option<Rc<RefCell<dyn IItem>>>, 
    pub _next : Option<Rc<RefCell<dyn IItem>>>, 
}

impl IItem for Item {
    fn get_val(&self) -> i32 {
        return self._val;
    }
    fn set_val(&mut self, mut value : i32) -> i32 {
        self._val = value;
        return self._val;
    }
    fn get_prev(&self) -> &Option<Rc<RefCell<dyn IItem>>> {
        return &self._prev;
    }
    fn set_prev(&mut self, mut value : Option<Rc<RefCell<dyn IItem>>>) -> &Option<Rc<RefCell<dyn IItem>>> {
        self._prev = utils::clone_opt_ref(&value);
        return &self._prev;
    }
...
    fn inc(&mut self) {
        self.set_val(self.get_val() + 1);
    }
    fn as_item(&self) -> &dyn IItem { self }
    fn as_mut_item(&mut self) -> &mut dyn IItem { self }
    fn get_base_class(&self) -> &dyn IItem { self }
    fn is_class(&self, name : &str) -> bool { name == "Item" }
}

impl Item {
    pub fn new(mut __val : i32) -> Item {
        let mut self_result = Item {  _val : 0,  _prev : None,  _next : None,  m_str : NString::null() };
        self_result.set_val(__val);
        self_result
    }
}

А вот наследный класс:


pub struct ItemChild {
    pub base : Item, // экземпляр базового класса
}
impl IItem for ItemChild {
    fn get_val(&self) -> i32 {
        self.base.get_val()  // вот здесь работа через экземпляр base
    }
    fn set_val(&mut self, value : i32) -> i32 {
        self.base.set_val(value)
    }
    // а это - переопределённая как бы виртуальная функция
    fn inc(&mut self) {
        self.base.set_val(self.get_val() * 2);
    }
    ....
}

impl ItemChild {
    pub fn new(mut __val : i32) -> ItemChild {
        ItemChild {  base : Item::new(__val) };
    }
}

Обращение к объектам Item и ItemChild везде идёт через ITrait, так что вызов inc() будет именно той функции, объект которой находится за этим trait — а это и есть полиморфизм! Что и требовалось доказать.


Ссылки


У каждой ссылки &T должно быть явно или неявно задано так называемое время жизни (lifetime), чтобы не оказалось так, чтобы ссылка жила дольше самого объекта, на который ссылается. Если использовать ссылки в полях структур, то для самой структуры тоже нужно указывать время жизни: struct A<'a> { ref : &'a Item, ... }. При этом получается как бы новый тип, п��и использовании которого нужно учитывать это 'a. Это должно ещё коррелировать со временем жизни самого объекта. Короче, когда таких ссылок становится много, наступает lifetime-hell, как я его назвал. Да, Rust тоже внёс свой вклад в коллекцию этих хеллов!


Решение подсказали опытные товарищи: использовать конструкцию Option<Rc<RefCell<T>>>. Выше в примерах кода она уже встречалась. И применять правило — если на объекты класса ссылаются в нескольких местах, то использовать только эту конструкцию для ссылок. Хотя и это не гарантирует корректного освобождения памяти, так как при циклической зависимости обратная ссылка должна быть Option<Weak<RefCell<T>>>. "Вот тут, Василий Иванович, я и сломался! — Дурак ты, Петька..."


Послесловие


Не стану описывать все нюансы конвертации, но признаюсь, что задачу удалось решить лишь частично. Из всего SDK получилось перевести только блоки морфологии и онтологии, что составляет примерно 10% от планируемого. Остальное оказалось непосильным, да и время закончилось, пора возвращаться к своим лингвистическим задачам. Первый "подход к снаряду" показал, что задача в принципе решается, но требует относительно большой корректировки исходного кода C# — это подсказки конвертеру и переписывание недопустимых для Rust фрагментов. А выигрыш производительности получился у меня всего в два раза.


Как язык, Rust не прост, совсем не прост… Советских людей учили, что в жизни всегда есть место подвигу. Программирование на Rust — это, конечно, не подвиг, но что-то героическое в этом есть! Я это оценил на собственной шкуре и приветствую героев!