С лёгким налётом ржавчины или куда делся NULL

Original author: Imaculate
  • Translation

Давно начал следить за языком Rust, кажется, ещё до выхода версии 1.0. За это время язык сильно изменился, оформился и стал совсем взрослым, можно в производство. При этом из коробки программисту предлагается довольно много интересных концепций для разработки надёжного ПО с длительным жизненным циклом. Однако сфера промышленной автоматизации не так динамична (как нам иногда бы хотелось), поэтому пока приходится только присматриваться к Rust. Тем не менее надо знакомиться поближе. Просто так читать книжки или заметки не продуктивно, надо что-то пробовать делать. Например, можно начать с решения задачек на LeetCode (что я и решил попробовать). А при решении таких задачек иногда натыкаешься на что-то такое, с чем и Stack Overflow может не помочь, не только книжки. В результате поисков дополнительной информации наткнулся на серию заметок, которой хотелось бы поделиться с общественностью (да-да, он воровал тексты у богатых и переводил их бедным). Под катом перевод первой маленькой заметки про (отсутствие) NULL в Rust.

Большинство ключевых функциональных возможностей Rust не являются новыми или уникальными — функциональное программирование, объектно-ориентированное программирование, метапрограммирование, отсутствие NULL, управление памятью без сборщика мусора — всё это существовало и раньше. Но Rust выгодно отличается в той области, для которой он изначально задумывался — безопасная работа с памятью и производительность во время выполнения. В этой серии заметок поговорим о некоторых из перечисленных функциональных возможностях.

Как и обещал, начнем с NULL. Что на самом деле означает NULL? В большинстве языков он обозначает ничто — пустое, недействительное, нулевое или условное значение, которое может быть заменой для любого типа. NULL был придуман сэром Тони Хоаром в 1965 году и с тех пор пользуется популярностью. Rust является одним из немногих языков программирования, которые не поддерживают NULL, и небезосновательно.

Начнём с того, что сам сэр Хоар назвал NULL ошибкой в миллиард долларов. Мне, как разработчику преимущественно на C (или на C++, как автору оригинальной заметки) хорошо знакома боль от необходимости повсеместно добавлять проверки на NULL. Отсутствие хотя бы одной проверки может стать фатальным. Идея избегать ошибок, сбоев и уязвимостей звучит довольно привлекательно.

Вместо NULL язык Rust предоставляет перечисление Option, которое является общим для любого типа и имеет два варианта Some и None. Если есть вероятность, что объект типа T не имеет значения, то есть может быть None, его тип становится Option вместо T. Если попытаться использовать такой объект как T напрямую, компилятор будет люто (но понятно) ругаться. Поэтому не получится использовать объекты или ссылки, которые не определены, что устраняет целый класс ошибок.

Рассмотрим следующую программу на C++ в качестве примера:

#include <iostream>

using namespace std;

struct Wrapper
{
    int value;

    void print()
    {
        cout << value << endl;
    }
};

int main()
{
    cout << "Entrance" << endl;
    Wrapper *w = nullptr;
    w->print();
    cout << "Exit" << endl;
}

Программа замечательно компилируется, но её запуск приводит к сбою. Ошибка сегментации памяти возникает, когда разыменовывается нулевой указатель. Программу легко исправить, потому что она короткая и простая. Но в реальности это часто не так. С увеличением сложности, при передаче указателя в другие функции, ошибиться становится проще простого. Это позволит ошибке проявиться у заказчика, и может потребоваться несколько дней (хорошо если так) для отладки.

Буквальный перевод программы на Rust будет выглядеть следующим образом, но, конечно, он не будет компилироваться:

fn main()
{
    println!("Entrance");
    let w: Wrapper = None;
    w.print();
    println!("Exit");
}

struct Wrapper
{
    value: i32
}

impl Wrapper
{
    fn print(&self)
		{
        println!("{}", self.value);
    }
}

Объект w не может иметь тип Wrapper и иметь значение None. Если не получается присвоить ему значение, он должен иметь тип Option, который затем необходимо будет развернуть, чтобы получить объект Wrapper. Простой и безопасный способ — оператор if (в данном случае if let), который использует w, если это какое-то значение, и игнорирует его в противном случае. Следующая версия программы делает именно это и работает корректно:

fn main()
{
    println!("Entrance");
    let w: Option<Wrapper> = None;
    if let Some(v) = w
    {
        v.print();
    }

    println!("Exit");
}

И хотя в C++ есть аналогичный необязательный тип Optional со значением NULL, он настолько же полезный, насколько обязательный. Компилятор Rust, наоборот, не будет компилировать программу без всех необходимых проверок, если используется Option (а без него нельзя использовать None, т.к. NULL же нет).

Итак, какое впечатление от Rust на данный момент? Неудобно для идеального результата? Больше чем просто разница в синтаксисе? Чрезмерно усердный компилятор? Всё вышеперечисленное? Верно. Следите за обновлениями и мы вместе узнаем, что делает Rust таким, какой он есть.

Другие заметки цикла:

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 19

    0
    use std::ptr;
    
    fn main(){
        let p: *const i32 = ptr::null();
        println!("{:?}", p);
    }

    Мне кажется, вы не очень далеко прочитали про Rust.

      +1
      Замечание в целом верное, пока ещё читаю. :) Полагаю, что для связи с кодом на Си (в котором активно используется NULL) приходится выкручиваться через сырые указатели std::ptr. Для самого Rust такой концепции как NULL нет. А вот возможность есть. И это хорошо.
        0

        Примитивный (базовый) тип pointer может иметь и null, и прочие гадости.


        https://doc.rust-lang.org/std/primitive.pointer.html


        Все остальные благородные типы (вроде slice или box) внутри имеют эти самые pointer'ы.

        +6

        Только этот код не упадет, потому что разыменования не происходит, и указатель мало отличается от обычного числа. А разыменование придется оборачивать в unsafe-блок, то есть вручную дать гарантии отсутствия нулевого указателя.


        https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=6e21d9c0d908030bc1a9440f5a5146aa

          –3

          Безусловно, это так. Разименование ptr — это unsafe, а часто и прямое UB (в зависимости от степени ужаса в указателе). Я привёл этот код для того, чтобы показать, что в Rust есть и указатели, и NULL.


          Дальше нужно обсуждать, что такого волшебного было сделано в стандартной библиотеке, языке и системе типов, чтобы можно было мирно порхать по result'ам и box'ам ни разу не наткнувшись на луркающее снизу UB.

            +2
            в unsafe по очевидным причинам есть всё, что есть в си, но когда говорят про rust и безопасность, то таком контексте речь всегда идёт о safe подмножестве и для удобства можно автоматом вставлять копипасту про safe подмножество, чтобы не рождать бесполезные споры
              –1

              У меня как раз появлось ощущение, что в интернетах образовался особый пласт Rust-о-фанов, которые Rust толком не знают, зато несут особый свет особого неофитства о том, как Rust, волшебным взмахом safe делает всё safe и какой он safe, и что в нём нет указателей и NULL, и не может быть ub.


              Хотя на самом деле, Rust — это такая специальная дисциплина ума, плюс разумные дефолты, выписанные в виде языка, которые дают возможность меньше думать про тлен и больше думать про сложное. В отсутствие дисциплины код на Rust'е превращается в такую же кашу, как и на любом другом языке.

                +3
                rust подмножество safe просто даёт хорошую абстракцию, которая не течёт

                это позволяет «забыть» о том, что внутри инкапсуляции, так как это правильно — «не знать» как там устроены внутренности. А знать приходится начинать тогда, когда они начинают протекать и является некачественным поделием. Всегда, когда разработчик должен знать что внутри X — это кривые руки архитектора интерфейсов X. Под словом «знать» я имею в виду принимать решения учитывая внутреннее устройство, а не настоящее знание деталей устройства

                Потому если какой-то язык или инженерный механизм и так далее требуют знания всё более мелких деталей внутри это говорит не о мощности этого инструмента и крутости его эксплуатантов, а лишь о дырявости абстракций, которые применяются в этом добре

                rust разделяет уровни абстракции на safe и unsafe делая так, что в safe протечки не происходят, в отличие от тонущего в протечках как дуршлаг cpp и прочих подобных языков

                Именно это и делает rust тем, чем он является, остальное это просто следствие разделения абстракций на правильные уровни
                  +1

                  Я не чуть не собираюсь оправдывать С++; более того, я очень люблю Rust. Но!


                  safe в rust даёт достаточно ограниченные гарантии. Я сейчас попытался найти тот пример, которым мне ткнули недавно, где сделали реальный wtf UB в совершенно safe-коде всего лишь с комбинацией двух lifetime'ов и одного static'а (на чтение). Увы, не нашёл.


                  Более того, многие не понимают, что от чего именно safe Rust. Там всего лишь хотят сделать так, чтобы не было UB. В ходе этого "всего лишь" оказывается, что очень много "edge case'ов" других языков — нифига не 'safe' (вставление в set элемента при итерации по нему, например).


                  А вот вторая часть Rust, которую почему-то не замечают, на самом деле, значит куда больше, чем война с UB (под словом safe). Например, очень, очень, разумные дефолты. Copy для непримитивных типов только в явном виде (move по-умолчанию), ничего не public пока не сказано обратное, всё readonly пока не попросили mut, никаких type elision в неожиданных местах (foo as usize 100500 раз), запрет на модификацию чужих трейтов для чужих типов.


                  Я бы сказал, что вот эта часть (разумные строгие дефолты) значит куда больше, чем культ вокруг safe. Во многих языках unsafe (в контексте raw pointers) просто нет, и они себя отлично чувствуют, т.е. само по себе "скрытие" ptr — это не особое достижение.

                    +3

                    Вот пример, воспроизводящий упомянутую вами проблему (использование после освобождения):


                    static R: &&i32 = &&0;
                    
                    fn aux<'a, 'b>(_: &'a &'b i32, arg: &'b i32) -> &'a i32 {
                        arg
                    }
                    
                    fn foo() -> &'static i32 {
                        let a = 0;
                        let bar: fn(_, &i32) -> &'static i32 = aux;
                        bar(R, &a)
                    }
                    
                    fn main() {
                        let res = foo();
                        println!("{}", *res);
                    }

                    Запустить


                    И это — баг в компиляторе: https://github.com/rust-lang/rust/issues/25860

                      +1

                      Все-таки данная заметка — не про UB, а про неудобства с использованием null и "падения" программ из-за него. Думаю здесь уместнее было бы сравнение с Java (или с другим языком, имеющим null), а не с указателями C/C++.

                        0

                        Да, в контексте сравнения null джавы (который не ведёт к UB, а просто к runtime ошибкам) — у раста есть некоторые преимущества, но..


                        let a = foo().unwrap()

                        или экивалент jav'овый без проверки на null на самом деле одно и то же. Вернули None/Null — упади. Т.е. компилятор подразумевает обработку ошибок, но вовсе её не требует. В этом смысле, кстати, [] в rust ведёт себя так же, как и java-код. Вылетел за границы — вот тебе паника.

                          +3
                          Т.е. компилятор подразумевает обработку ошибок, но вовсе её не требует.

                          Ну как же? Очень даже требует, иначе не пришлось бы явно писать unwrap. Не требует "обрабатывать правильно" — да, но в общем случае это невозможно (где-то и падать на панике — вполне нормальное поведение).


                          С нулабельными типами проблема в том, что нельзя статически гарантировать наличие значения, вот и вылетают NullPointerException периодически.

                            0

                            а, почитал, что в java. У них всё может быть null, и невозможно сконструировать non-nullable тип. Бедные, бедные джависты.

          0

          Получается, вместо постоянных проверок на NULL в Си, мы получаем постоянные поверки, есть ли что то в Option? Мне кажется, или это одно и то же.

            +3

            Не совсем. В Си любой указатель может быть действительным, некорректным или NULL. В Rust только Option может быть либо None, либо чем-то (Some). Использовать Option в виде объекта не получится — компилятор не позволит. Если будете вытаскивать из Option, то компилятор заставит сделать это правильно. Если уже вытащили из Option, то больше проверять не нужно — это не будет None. И не может быть некорректных ссылок.

              +1
              Да, разница только в обязательности. На C/C++ вы можете говнокодить как вам вздумается, а компилятор Rust вам этого не даст.
                +4
                Option как-раз позволяет избежать лишних проверок там, где они не нужны.
                Используйте Option там, где может быть NULL, но там где NULL не может быть никогда, используйте указатели и не делайте никаких проверок. Код станет чище и понятнее.
                –3
                Открыть эту тему (уже открыто), вторым окном открыть ютюб — в рекомендациях будет это:
                youtu.be/cdX8r3ZSzN4

                Only users with full accounts can post comments. Log in, please.