
Краткое примечание для читателей, не знающих о C3: это язык системного программирования, продолжающий традиции C. В статье приведена специфика C3, но все плюсы и минусы применимы к любому языку, в котором нужно выбирать типы для размеров и длин.
C3 переходит к использованию типов со знаком по умолчанию, но почему? Разве как минимум для размеров правильнее не использовать беззнаковые типы? Попытаемся ответить на этот вопрос.
Баги беззнаковых типов
С самого начала проекта в C3 использовались беззнаковые размеры. И хотя имя беззнакового типа со временем менялось с «usize» на «usz» (после объединения с типом uptrdiff), оно всё равно оставалось используемым по умолчанию.
Однако у беззнаковых типов есть хорошо известные изъяны; вот самый известный из них:
for (uint x = 10; x >= 0; x--) // Бесконечный цикл! { ... }
На самом деле, этот баг так легко вызвать, что C3 вне пределов макросов явным образом отклоняет x >= 0 для беззнаковых типов.
Ещё один классический баг C:
uint a = 0; int b = -1; if (a > b) { ... }
В C оба значения будут превращены в беззнаковые, из-за чего b становится огромным беззнаковым значением, поэтому сравнение выполнится неверно. По этой причине в C3 реализованы безопасные сравнения знаковых/беззнаковых типов, которые не преобразуют обе части, обеспечивая при этом безопасность.
Разумеется, в C допускаются косвенные преобразования между беззнаковыми и знаковыми типами. Хоть это и становится источником багов, я посчитал, что благодаря добавлению мер защиты это по большей мере можно оставить в C3.
Можно подумать, что представленные выше баги — это никак не связанные друг с другом особенности языка. Непрекращающийся цикл, поломанное сравнение, преобразования, с которыми нужно соблюдать аккуратность... всё это произрастает из одного раннего решения: по умолчанию для размеров должны использоваться беззнаковые типы. Основная часть моего поста посвящена этому решению.
Уместный вопрос
Можно вполне резонно задать вопрос: «Но почему бы просто не сделать обязательным явное преобразование между знаковым и беззнаковым?».
Как оказалось, причина заключается в беззнаковых размерах.
Если размеры беззнаковые, как в C, C++, Rust и Zig, то из этого следует, что всё, что касается индексации в данных, должно быть беззнаковым или требовать преобразований типов. В случае свободной семантики C на эту проблему в основном закрывают глаза, но в Rust из-за этого при работе с размерами пришлось бы регулярно выполнять преобразования туда и обратно.
Существует два подхода к реализации преобразования типов: во-первых, можно произвольно разбросать их по всей кодовой базе по принципу «это явное преобразование, поэтому происходящее очевидно». Во-вторых, можно минимизировать преобразования типов, используя их только для того, чтобы сигнализировать о выполнении чего-то неординарного.
Первое решение проще определить, но его недостаток в том, что фактически оно «заглушает предупреждения». Допустим, код изначально должен был преобразовывать тип из u16 в u32, но позже тип переменной сменился с u16 на u64, и преобразование теперь незаметно обрезает данные. Получается, преобразования типов превратились в умалчивание всех предупреждений.
Кроме того, принцип «это явное преобразование» подрывается тем, что разработчик механически подставляет преобразования там, где они нужны по мнению компилятора, а не пытается изучить каждый случай.
С другой стороны, минимизировать количество преобразований сложно: необходимы правила для корректного допущения «безопасных» косвенных преобразований, а также явные преобразования для того, что небезопасно.
C3 пошёл по второму пути: преобразования должны что-то значить, но почему он разрешает беззнаковые <-> знаковые? Это ведь небезопасно?
Оказывается, что если использовать только сложение, вычитание и умножение, это по большей мере достаточно безопасно, если целочисленные значения со знаком представлены в дополнительном коде. А учитывая то, что преобразования должны происходить часто (помните: размеры беззнаковые!), естественно было сделать их косвенными.
Гладко было на бумаге
В основном современная семантика преобразований оставалась в C3 неизменной с 2021 года и в течение пяти лет работала достаточно хорошо, не вызывая никакой серьёзной нежелательной семантики; но затем невинный вопрос о (foo + a) % 2 перевернул все эти допущения.
Чтобы избавиться от возможностей выстрелов в ногу, в C3 было решено, что «int + uint» преобразуется в «int», а не в беззнаковый тип. Благодаря этому гораздо большее количество случаев получало знак, что в большинстве случаев было правильным решением. Но что делать, если мы выполняем (foo + a) % 2, и foo здесь оказывается больше INT_MAX? Внезапно мы получаем необъяснимые результаты! Правильным ответом оказывается (foo + a) % 2U.
Эта проблема была неприемлемой. Не потому, что её сложно устранить, а потому, что она оказалась столь неожиданной. Почти во всех случаях можно было игнорировать то, происходило ли косвенное преобразование в знаковый тип, всё просто работало. Но в случае / и % это решение ломалось. А поскольку оно «просто работало» во всех остальных случаях, было довольно непонятно, окажется ли подвыражение знаковым или беззнаковым. Удобство превратило эту маленькую проблему в серьёзную.
Первым делом мы решили это пропатчить: просто выдавать ошибку при выполнении «знаковый / беззнаковый» и «беззнаковый % знаковый», но в тени таились новые проблемы.
Сложный возврат
Если вы пишете кольцевой буфер, то как проверить, что вычисляемые смещения циклически переполняются и возвращаются в начало?
Наивное решение было бы таким:
index = (start + offset) % length;
Оно работает, пока offset положительное. Но что насчёт отрицательных значений? Вот популярное простое решение:
index = ((start + offset) % length + length) % length;
Так как offset отрицательное, можно предположить использование знаковых чисел, поэтому сработает запрет чрезмерно больших значений (вызывающих переполнение знакового значения).
(Примечание: если бы % возвращал остаток по модулю, а не от деления, то наивное решение сработало бы).
Помните, с чего мы начали тему о беззнаковых размерах? Если использовать их в первую очередь, то это скорее всего приведёт к применению всех беззнаковых, а код начнёт выглядеть так:
index = ((start - offset_back) % length + length) % length;
Что совершенно неправильно, а ещё это сложно обнаружить. Иногда такой код будет выполнять циклическое переполнение корректно, но чаще всего нет.
Корректный код для беззнакового типа должен выглядеть примерно так:
index = (start + length - (offset_back % length)) % length;
Какие бы правила мы ни применили к преобразованиям беззнаковых и беззнаковых в знаковые, компилятор просто не сможет сообщить нам, когда первый пример «offset_back» окажется поломанным для беззнаковых.
Поэтому давайте немного вернёмся назад.
Беззнаковый размер
Похоже, решить проблему беззнаковых сложно; возможно мы принимаем какое-то ошибочное допущение?
Обратимся к истории: изначально в C по большей мере использовались знаковые целые, основанные на типе int. Всё поменялось, когда тип sizeof был стандартизирован, как беззнаковый size_t.
Из-за этого единственного изменения беззнаковая арифметика стала часто применяться в коде на C. Найдя эту новую привлекательную фичу, разработчики начали использовать беззнаковые значения для обозначения того, что они не могут быть отрицательными, и рассказывать другим, что применение беззнаковых помогло им, потому что позволило выражать суммы большего размера.
Но это не значит, что проблем не было. На самом деле, проблемы были столь существенными, что в 90-х создатели Java решили полностью отказаться от беззнаковых типов. Возможно, их реакция была слишком резкой, но зато она позволила достичь цели: устранить большое множество распространённых багов, связанных с беззнаковыми типами.
Go должен заставить нас призадуматься: это низкоуровневый язык, созданный как реакция на проблемы C++ людьми, точно знавшими, как приходится расплачиваться за беззнаковые размеры, поэтому они выбрали размеры со знаком.
При работе с целыми ограниченного вида проблемы возникают, когда мы приближаемся к их границам. Для 32-битного int со знаком это примерно от минус до плюс двух миллиардов, а для беззнакового 32-битного — от нуля до примерно четырёх миллиардов. «Небезопасные» границы у беззнаковых значений находятся намного ближе, чем у знаковых, они просто не сравнимы.
Именно поэтому мы встречаем проблемы в случаях наподобие %.
Но что насчёт диапазона? Хоть мы действительно можем удвоить диапазон, код в диапазоне выше максимума знакового int на удивление часто оказывается полон багов. Любой код, выполняющий в этом диапазоне что-то наподобие (2U * index) / 2U, ожидает довольно неприятный сюрприз. Но на самом деле, всё ещё хуже: переполнение в случае значений со знаком обычно приводит к получению недопустимого отрицательного числа, однако беззнаковое переполнение часто создаёт вполне правдоподобное, но ошибочное значение. Не говоря уже о том, что на современных 64-битных машинах у нас скорее закончится память, чем мы полностью израсходуем весь диапазон 64-битных целых чисел со знаком.
Допустим, но не ценно ли то, что мы изначально находимся в нужном диапазоне? Судя по работе с фреймворками верификации, ответ здесь отрицательный, поскольку беззнаковые типы кодируют только поведение по модулю и фактические диапазоны значений. Можно возразить, что переполнение беззнаковых типов можно сделать ошибкой (именно так, например, поступает Rust), но тогда теряются полезные свойства беззнаковой арифметики: выражение (a + b) - c равно a + (b - c) при циклическом переполнении беззнаковой арифметики, но не равно, в случае отсутствия такого переполнения. Это само по себе ловушка.
Итак, можно сказать, что по исторической случайности беззнаковые значения используются довольно часто. Они подвержены ошибкам и скрывают их. Возможно, решение будет заключаться в том, чтобы сделать их более эргономичными?
Знаковые типы по умолчанию
Как вы могли уже догадаться, мы решили по умолчанию применять в C3 знаковые типы для размеров и длин. Так как теперь беззнаковые будут использоваться реже, нам не нужны никакие косвенные преобразования между беззнаковыми и знаковыми. Сравнения между знаковыми и беззнаковыми тоже пропали.
При внесении этого изменения я начал также избавляться от несвязанных с этим случаев использования uint и ulong, обнаруживая код, который казался подозрительным или откровенно неверным. Кроме того, когда повсюду начали использоваться только int и знаковые размеры, код стал проще. И на этом этапе я осознал, что затраты на использование беззнаковых типов заключались в моих внутренних усилиях: если поработаешь какое-то время на C или C++, то у тебя появляется привычка искать возможные проблемы, связанные с беззнаковыми типами, или использовать менее очевидные паттерны, чтобы они точно работали и для знаковых, для беззнаковых переменных.
Меня неприятно поразило то, что на это изменение потребовалось так много времени; это стало доказательством того, насколько глубоко въелась в меня эта привычка. Я просто считал, что беззнаковые размеры — это нужное решение, и что проблема заключалась лишь в повышении эргономики и устранении максимального количества тонких моментов. И всё это несмотря на то, что Go и Java показали нам путь работы со знаковыми размерами.
Но даже после принятия решения об этом изменении оно поначалу казалось мне неудобным и ошибочным, как будто я делаю нечто запретное — вот настолько далеко я зашёл. Но видя, что каждое изменение не только упрощало понимание кода, но и делало его более корректным, я не мог отрицать выгоды.
Примечания об изменениях в C3
Перед реализацией этого изменения его обсуждали на Discord-сервере C3, где оно получило громкое название «iszmageddon» в честь типа isz (приблизительно соответствующего ssize_t), который должен был стать типом для размеров по умолчанию.
Чтобы более чётко заявить о знаковом размере, он был переименован в «sz», благодаря чему в версии 0.8.0 появилась асимметричная пара sz/usz. Так легко запомнить, какой из них предпочтительнее использовать. Поэтому изменение переименовали в «szmageddon».
Изначально косвенное преобразование знаковые <-> беззнаковые в основном оставили без изменений, но позже от него полностью отказались.
Комментарии (0)

kunix
10.05.2026 07:20Мой персональный адочек с целочисленными преобразованиями:
off64_t lseek64(intfd, off64_toffset, intwhence);lseek64(fd, -sizeof(envelope)-0x10, SEEK_END);Работает на 64-битных системах и не работает на 32-битных.
Понимаете, почему?Я конечно же рукожоп и сам виноват.
Но мое мнение - неявные преобразования между знаковыми и беззнаковыми надо запретить или хотя-бы ограничить. Человек должен явно все прописать и понимать, что он делает.

lightln2
10.05.2026 07:20Кажется, автор еще забыл упомянуть, что переполнение знаковых - это undefined behavior в C++, если делать offset/length знаковыми, то это придется как-то чинить.
Но совсем избавляться от беззнаковых тоже кажется не самой лучшей идеей, тогда, как в Яве, придется вводить оператор “>>>” (беззнаковый сдвиг вправо), и будут проблемы с поддержкой форматов хранения, где значения беззнаковые, например, массив uint8.
Но когда корректность важнее скорости, то имхо, знаковые оффсеты и длины массивов кажутся хорошим компромисом: можно вставить дополнительные проверки на неотрицательность при создании массива и обращении к его элементам (в 99% случаев оно не скажется на производительности, так как проверка будет на свободном ALU-порту, и branch prediction тоже отработает параллельно)
В общем, мне нравится подход как в C#, придуманный много лет назад:
длины и оффсеты массивов знаковые, исключения при обращениях out-of-bounds
есть беззнаковые, если нужны, более-менее разумные правила автоматического преобразования
если важно не поймать overflow, есть опциональный checked{} контекст, в котором оно вызовет исключение
Eсли надо выжать последние полпроцента производительности, есть unsafe{} контекст, в котором можно создать массив байт через malloc, и дальше уже с ним извращаться, как душе угодно.
Apoheliy
Сарказм: через 5 лет ребятки осознают, что в целочисленных числах есть фундаментальная проблема с точностью при операциях деления, и предложат всё перевести в плавающую точку!
---
Это какая-то имитация бурной деятельности. Ограничения типа (по хорошему) должны соответствовать самой сущности (и ограничивать неправильное использование), иначе это превращается в костыли. Когда же пытаются сделать идеализированно - получается много попоболи.
Знаковые числа тоже обладают рядом "косяков":
...-дцать лет назад пытался пофиксить библиотеку Corba на C#: там для номера сетевых портов использовалось знаковое двубайтное число: двубайтное - чтобы значение заталкивать в протокол; знаковое - потому что C#. И ЭТО ПРЕВРАТИЛОСЬ В ЦИРК: эти значения портов постоянно преобразовывались; и в основной функциональности это ещё как-то работало. Как только отходил немного в сторону - то библиотека работала только с сетевыми портами до 327хх. Было весело, результат - решили отказаться от C#.
Ну и классическое: модуль знакового числа (например, двубайтного) иногда не помещается в свои же размеры. Это периодически используется (например, обработка звука: вычисление амплитуды) и если отдельно за этим не следить, то налетаешь "на всякое".