Привет! Представляю вашему вниманию перевод статьи "The dark side of ergonomics".
Дисклеймер: Тема, о которой я собираюсь рассказать, несколько спорна и потенциально непопулярна. Я не собираюсь троллить, провоцировать холивар или задевать чьи-либо чувства. Не давайте моему несогласию с чем-то препятствовать вам. Если бы вы и люди вроде вас не проделали столько отличной работы над Rust, я бы не стал о чем-либо спорить. Моё намерение в том, чтобы поделиться иной точкой зрения и начать разумную дискуссию, а не войну. Поэтому, я попрошу кое о чём. Не соглашайтесь с тем, что я пишу, если хотите, но попробуйте обдумать это. И если у вас возникнет желание прокомментировать, сделайте это, но, может быть, дайте себе полчаса на то, чтобы эмоции остыли. У меня тоже есть эти чувства, и обещаю, что постараюсь сделать то же самое (я перечитываю эту статью уже несколько часов).
Несмотря на опыт с широким спектром языков, включая C++ и Haskell (оба оказали большое влияние на Rust), я вижу, что Rust сложен в обучении. Иногда зубы скрипят от того, что компилятор что-то мне не позволил. Несмотря на это, я не хочу эргономики во всём. Фактически, если бы мне нужно было сделать выбор прямо сейчас, я был бы скорее против дальнейших эргономических инициатив. Rust никогда не делал мне ничего действительно плохого, чего я, вероятно, не могу сказать ни об одном другом языке, который я использовал в реальном проекте. У меня есть для него свой бесконечный список претензий, но они не заходят далеко.
Вероятно, вы сочтёте меня сумасшедшим. Ведь почему кто-то не хочет, чтобы его язык был более простым в обучении и использовании? Вы можете счесть меня сварливым стариком, который против всего нового. Возможно, вы будете отчасти правы и в том, и в другом. Может быть, я просто справился с количеством багов, бо́льшим, чем хотелось бы, и видел слишком много сломанных вещей, так что я чересчур параноик. Вот, что с тобой делают годы на C++ (и других языков в меньшей степени). После этого вечного жонглирования бензопилами, я боюсь что-либо изменить в страхе, что это нечто может стать одной из них.
Но, кроме того, я считаю эргономику обоюдоострым мечом. Другие цели Rust кажутся мне более важными. Это его надёжность, абстракции без багов, или как это называть. Просто сам факт того, что если мой код компилируется, то уже есть большие шансы, что он корректен, и что если я отправлю его сразу в продакшн (безо всякого тестирования), то этот код будет работать и завтра. Это достаточно уникально, по крайней мере, среди мейнстримных языков.
На очень высоком уровне, эргономика и надёжность обязательно противоречат друг другу. Я достигаю надёжности, отказываясь от сломанного и подозрительного кода на компиляции. Я достигаю большей эргономики, принимая больше кода и надеясь, что он делает то, что автор имел в виду, так что мне не придётся тревожить автора ошибкой компиляции.
Поэтому, хотя я знаю, как много людей хотят больше эргономики, я не уверен, что это то, что им нужно.
И это не только в теории. Давайте рассмотрим несколько случаев. Это в некотором роде крайние случаи, которые лучше иллюстрируют идею. И это очевидные случаи, когда явное эргономическое улучшение может вредить общему опыту.
Я не утверждаю, что видел какие-то серьёзные предложения по исправлению этого. Но такие тонкие случаи существуют, и в такие моменты у меня возникает большое стремление комментировать RFC. Не для того, чтобы просто препятствовать автору, а потому что у меня другая точка зрения. Мы оба хотим лучшего для Rust, у нас просто разные представления о том, что это значит.
Пустые значения
Все знают об ошибках доступа к NULL и различных их вариантах (None type has no attribute
в Python, NullPointerException
в Java). Не будем говорить о серьёзности последствий. В любом языке доступ к пустому значению — это ошибка. Да, есть языки, в которых у вас есть какое-то значение по умолчанию (0, если это число, пустая функция, если вызываете пустое значение), но это, вероятно, тоже ошибка. Если вы полагаетесь на это и делаете это специально, лучше оставьте комментарий.
У Rust есть свой вариант (.unwrap()
или паника при None
). Как так выходит, что в Rust это происходит реже, чем в Java? Потому что в Rust доступ к чему-то, что может быть None
— явный. Написание .unwrap()
заставляет меня признать, что это может быть None
. Знаете, этот эффект "Вот блин, лучше мне обработать и это, да?". Это принуждает меня исправить мой образ
Но будет ли автоматическое приведение Option<T>
к T
, где это нужно, более эргономичным? Да, возможно. Кто хочет писать все эти unwrap
-ы и match
-и?
Каждый раз, когда мне приходится обрабатывать возможность пустого значения или ошибки, я вспоминаю, сколько раз это помогало мне в многочасовых поисках багов в системах, к которым у меня не было прямого доступа, а не думаю о том, как это неэргономично.
Неявное управление потоком выполнения
Да, я об исключениях. Во многих случаях они более комфортны, чем обработка этого на каждом уровне. Вам просто не нужно заботиться о том, что что-то может пойти не так, верно?
Неверно. Особенно, если у вас язык без сборщика мусора и если вы не хотите, чтобы исключения были фатальными или почти фатальными (в основном, доводя их до вида паники потока в Rust), то у вас очень тяжёлое время. И это время имеет своё имя, безопасность исключений.
Как видите, факт того, что вам не надо явно обрабатывать ошибки перевешивается фактом, что вам нужно особенным образом структурировать код из-за того, что в каждом месте может возникнуть исключение. В принципе, отсутствие заботы о том, что может пойти не так, означает, что вы должны предполагать, что пойти не так может всё. Это значит, что вы или делаете сложные откаты (или используете блоки finally
в других языках), или вы делаете состояние консистентным. Даже если вы хотите обеспечить слабую защищённость, по крайней мере должны убедиться, что деструктор не взорвётся в беспорядке состояния, которое вы потеряли во время исключения. Я даже не говорю о магии, которую должен делать за кулисами компилятор, чтобы RAII Guard работали.
И хотя в C++ есть ключевое слово noexcept
, которое может аннотировать функцию или метод, оно фактически бесполезно. Оно не проверит во время компиляции, что функция не выбрасывает исключений. Оно просто делает любое выброшенное исключение завершением программы. Кроме того, нет указаний, что семантика except
/noexcept
на стороне вызывающего и удаление ключевого слова из сигнатуры функции не приведут к тому, что все места, полагающиеся на семантику noexcept
, перестанут компилироваться.
Сравните это с Rust и его оператором ?
. Да, вы должны явно передать все "исключения" вверх. Вручную. Это тяжёлая работа. И вы должны действительно думать об этом. Но когда вы добавляете или удаляете возможность падения из этой функции, компилятор укажет на все места, где изменения имеют значение.
Если кто-то когда-либо предложит контекст, где они передаются автоматически, я буду в первых рядах пытающихся объяснить, что это не очень хорошая идея. Да, я работал на проекте, который давал гарантии по исключениям везде, даже в местах исключений std::bad_alloc
. И я подобного больше не хочу. Я хочу видеть места, которые могут "бросать" и "безопасные" места. И не только в коде, который написал я, но и во всём коде, что я читаю, когда происходит что-то вроде "Упс, этого некогда не должно было случиться".
Автоматическое приведение типов
Автоматическое приведение делает жизнь программистов проще, не заставляя их писать преобразование самостоятельно, да? Никаких глупых .into()
. Или как-то так долго думал комитет C++ (с тех пор он ввёл ключевое слово explicit
— забавно, как много проблем можно "решить" добавлением очередного слова, которое никто не научится использовать).
Для меня это довольно недавний опыт. Это растянуло мой поиск бага на 3 часа, поскольку это привело меня к неверной кроличьей норе.
Скажем, у вас есть класс. Он из сторонней библиотеки, так что вы его досконально не знаете, но после целого дня отчаянной битвы с этой глупой частью программы, вы применяете библиотеку с отладочным выводом в добавок к собственному коду. Знаете, потому что ничего кроме этого не работает — код валится в случайном месте после нескольких минут на полной скорости (хождение по шагам в отладчике займёт вечность), и баг совершается задолго до того, как произойдёт повреждение памяти, это никак не проявляется в valgrind (что намекает на состояние гонки), rr
бесполезен, и в конце концов вы благодарны за то, что у вас хотя бы есть отладочный вывод, и можно грепнуть эти многогигабайтные логи. Потом добавьте ещё вывода, дайте ей проработать несколько минут, почистите, повторите. По крайней мере, отладочный вывод надёжен, если больше ничего нет. В конце концов, всё хорошо, ведь программа воспроизводимо падает на вашем собственном компьютере, что в основном означает, что терпения у нас больше, чем ошибки.
class Token {
private:
uint32_t value;
public:
// Some fields omitted
operator bool() const {
return value;
}
bool operator ==(const Token &other) const {
return value == other.value;
}
bool operator !=(const Token &other) const {
return !(*this == other);
}
};
Как вы думаете, что этот код сделает?
const Token tok1 = getUniqueToken(), tok2 = getUniqueToken();
if (tok1 != tok2) {
std::cout << tok1 << "!=" << tok2 << std::endl;
}
Если вы правильно догадались, что он напечатает 1!=1
, то считайте себя членом клуба "Я видел столько сломанного кода, что сбился со счёта". И это там, где вместе весь важный код. Такого со мной не было, и это не совсем тот код, что был, поэтому я стал искать, почему токен был всегда равен 1 ‒ хотя это было не так. И если вы не догадались, считайте, что вам повезло, и вот подсказка: для этого типа есть operator bool
(автоматическое приведение к bool
) и нет operator <<
.
В любом случае, это эргономическое улучшение сломало весь надёжный отладочный вывод в тот момент, когда мне меньше всего хотелось шутить. Я не считаю комитет C++ некомпетентными людьми. Напротив. И даже они не смогли предвидеть все последствия, поэтому давайте учиться на истории.
Фактический посыл
В заключение я хотел бы сказать, что я не против эргономики как таковой. Я просто немного боюсь этого, потому что видел слишком много эргономических улучшений, которые сказывались негативно либо по отдельности, либо в паре с другой возможностью языка. Я не утверждаю, что C++ (что был в двух примерах выше) в любом случае эргономичный, я говорю, что эти функции были введены во имя эргономики, и я нахожу его неплохим источником примеров (поскольку я использую в основном C++ в последнее время, а Rust второй на очереди, в сожалению).
Поэтому, когда я вижу новое эргономическое предложение, я несколько скептичен. Rust в этом смысле работает отлично в сравнении с вышеописанным. И знаете, не нужно исправлять то, что не сломано.
Я, вероятно, слишком боюсь нарушить неплохие и уникальные свойства Rust. Не могу сказать, что я против всего и вся. Например, NLL
замечательны, на мой взгляд, потому что они проходят тест "лакмусовой бумажки" ‒ даже при больших стараниях, я не могу придумать случай, когда они могли бы скрыть ошибку.
Я полностью понимаю, что мой баланс в том, что стоит того, а что ‒ нет, сильно перекошен слишком долгой жизнью лицом к опасности как в сложном, долгоживущем коде, так и в не очень отзывчивых языках. Возможно, я вижу монстров везде, даже там, где их нет. Но не для этого ли существует Rust?
Посыл, который я хочу донести, не в остановке всех эргономических инициатив ‒ я не считаю, что неэргономичность без повода ‒ это хорошо; а в том, что у эргономики есть недостатки их необходимо учитывать с преимуществами. Rust ‒ язык, помещённый в грубые места. Я бы предпочёл, чтобы он дал мне всё тяжёлое и неудобное оружие, что мне нужно, чем чтобы он убеждал меня в том, что в подвале нет монстров.
Если я когда-либо комментировал ваше предложение чем-то вроде "Но что если это странное совпадение произойдёт, и новая версия зависимости тоже сделает это, не взорвётся ли всё?", то я просто боюсь взрывов. Потому что у меня очень хороший талант попадать в такие странные условия. Это попытка помочь исправить предложение, чтобы ошибки не полезли из всех щелей.
Автор: рекрут