# SQLite-расширение на C оказалось в 23 раза меньше Rust, но Rust сначала проиграл только после оптимизации C
Каноническая страница: https://plainews.ru/posts/sqlite-parse-number-c-rust
Опубликовано: 2026-11-09T07:01:38.744Z

Как выбрать C или Rust для SQLite-расширения parse_number(): результаты тестов на 2 млн строк, размеры .so и критерии для проекта.
Функция parse_number(value) помогает превратить строки вроде -$1,234.56 и 12.5% в числа, которые SQLite сможет сортировать и суммировать. В исследовании Simon Willison сравнил две реализации такого расширения: C и Rust — на размере файлов и скорости обработки 2 миллионов строк.

Итог не сводится к привычному «C быстрее, Rust удобнее». Первые Rust-версии обогнали C, но ручной парсер на C с «быстрым путём Клинджера» (Clinger’s fast path) оказался быстрее всех вариантов.

## Сначала определите контракт parse_number(), иначе бенчмарк будет бесполезен

Для такой SQLite-функции важно заранее зафиксировать, какие входные значения она принимает и в каких случаях возвращает NULL. В исследовании parse_number(value) должна удалять символы ,, $, £ и %, учитывать ведущий - и возвращать NULL, если строку нельзя распознать как число.

Это не просто деталь реализации. Контракт определяет, какие данные попадут в расчёты и не превратит ли функция ошибочную строку в правдоподобный ноль. Например, строка с валютой и разделителем тысяч должна стать числом, а текст, который нельзя интерпретировать, — остаться NULL.

Примеры ожидаемых преобразований выглядят так:

| Исходное значение | Ожидаемый результат |
| --- | --- |
| `-$1,234.56` | отрицательное десятичное число |
| `12.5%` | десятичное число после удаления `%` |
| `£1,000` | число после удаления `£` и `,` |
| Нераспознаваемая строка | `NULL` |

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

```sql
SELECT
  parse_number(value) AS numeric_value
FROM imported_rows
ORDER BY numeric_value;
```

> **Важно.** Удаление `%` из строки означает только очистку символа в рамках описанного контракта. Исследование не утверждает, что `12.5%` автоматически превращается в долю `0.125`.

Для продуктовой задачи этого достаточно, чтобы отделить нормализацию отображаемых значений от вычислений. Лучше сохранять исходную строку отдельно: так можно проверить, почему конкретная запись превратилась в NULL, не теряя исходные данные.

## C дал файл на 14 KB, а Rust-версии заняли почти 300 KB

Размер артефакта стал самым заметным различием между языками. Реализация SQLite-расширения на C заняла 14 KB, тогда как вариант на Rust с Rusqlite занял 321 KB, а реализация с sqlite-loadable-rs — 298 KB.

Для одиночного расширения разница кажется косметической, пока расширение не нужно распространять вместе с CLI-инструментом, desktop-приложением или набором SQLite-плагинов. В таком сценарии 14 KB против сотен килобайт влияют на размер релиза, но не обязательно должны быть единственным критерием выбора.

| Реализация | Размер результирующего файла |
| --- | --- |
| C | 14 KB |
| Rust с `Rusqlite` | 321 KB |
| Rust с `sqlite-loadable-rs` | 298 KB |

C-версия получилась примерно в 23 раза меньше Rust-варианта на sqlite-loadable-rs и почти в 23 раза меньше варианта с Rusqlite. При этом размер не говорит ничего о корректности обработки краевых случаев, удобстве сопровождения или стоимости разработки.

Для разработчика практический вывод такой: C подходит, когда расширение должно быть предельно компактным и поставляться как маленький нативный модуль. Rust стоит рассматривать, если в команде уже есть Rust-код, важна безопасность работы с памятью или расширение будет развиваться дальше простого парсинга строк.

В исследовании сравнивались именно эти конкретные реализации, а не языки «вообще». Нельзя переносить разницу в 14 KB против 298–321 KB на любое SQLite-расширение: итоговый размер зависит от используемых библиотек, режима сборки и содержимого модуля.

## Первые Rust-реализации были быстрее C на смешанных и дробных данных

Неоптимизированная C-версия проиграла обоим вариантам на Rust. Simon Willison связывает это с тем, что Rust-эквивалент C-функции strtod оказался «в два раза быстрее».

Замеряли чистую стоимость на одну строку при обработке 2 миллионов строк: чем меньше значение в наносекундах, тем лучше. В таблице first C — исходная C-реализация, new C — версия после замены парсера, а остальные два столбца — Rust-варианты.

| Набор данных | Первая C-версия | Новая C-версия | Rust: `Rusqlite` | Rust: `sqlite-loadable-rs` |
| --- | --- | --- | --- | --- |
| Смешанные данные | 67 ns | 19 ns | 46 ns | 41 ns |
| Только целые числа | 25 ns | 16 ns | 36 ns | 32 ns |
| Только десятичные числа | 106 ns | 18 ns | 55 ns | 47 ns |

До оптимизации C показывал 67 ns на смешанных данных против 46 ns у Rusqlite и 41 ns у sqlite-loadable-rs. На десятичных значениях разрыв был ещё заметнее: 106 ns у исходной C-версии против 55 ns и 47 ns у Rust-вариантов.

Это полезный антипример для выбора технологии. Низкоуровневый язык сам по себе не гарантирует лучший результат: в этом случае узким местом стал способ преобразования текста в десятичное число, а не SQLite API и не накладные расходы вызова расширения.

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

## Быстрый путь Клинджера сделал C лидером во всех трёх замерах

После следующего запроса Claude переписал C-парсер так, чтобы он использовал Clinger’s fast path. Эта версия C стала самой быстрой на смешанных строках, целых числах и десятичных значениях.

Новая C-реализация обработала смешанные данные за 19 ns на строку. Это быстрее sqlite-loadable-rs с результатом 41 ns и Rusqlite с 46 ns. На данных только из дробных чисел преимущество ещё выше: 18 ns у новой C-версии против 47 ns и 55 ns у Rust.

| Набор данных | Лучшая реализация | Стоимость на строку |
| --- | --- | --- |
| Смешанные данные | Новая C-версия | 19 ns |
| Только целые числа | Новая C-версия | 16 ns |
| Только десятичные числа | Новая C-версия | 18 ns |

Похоже, ключевой урок здесь не в том, что нужно всегда писать парсеры вручную. Он в том, что производительность нужно измерять на собственном распределении данных: отдельно на целых числах, дробях и смешанном наборе, если именно такие данные приходят в продукт.

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

## Где ломается выбор «C или Rust» без тестового набора

Главная ловушка — принять бенчмарк на 2 миллионах строк за универсальный результат. Он показывает поведение конкретных реализаций parse_number() с тремя типами наборов: смешанные данные, целые числа и десятичные числа.

Вторая ловушка — ориентироваться только на размер .so-файла. C-расширение в исследовании занимает 14 KB, но маленький бинарник не заменяет тестов на корректность и не делает поддержку автоматической. Rust-артефакт больше, однако первоначальные Rust-версии опередили первую реализацию на C.

Третья ловушка — путать очистку строки с финансовой интерпретацией. Удалить $, £, запятые и % — это один контракт. Решить, является ли процент процентным пунктом, долей или просто текстовым суффиксом, — отдельное продуктовое правило.

> **Важно.** Возврат `NULL` для непарсящихся значений защищает агрегаты от молчаливой подмены ошибок числом. Но запросы должны явно учитывать `NULL`, если бизнес-логике нужен список проблемных строк.

## Что попробовать дальше

Соберите собственный набор из реальных строк: валюты, отрицательные суммы, проценты, пустые значения и намеренно испорченные записи. Затем замерьте стоимость функции отдельно на смешанных, целых и десятичных значениях — именно так построено сравнение в исследовании.

Если размер поставки критичен, начните с C и проверьте, нужен ли сложный парсер. Если важнее скорость первой реализации и команда уже использует Rust, сравните Rusqlite и sqlite-loadable-rs на одинаковом наборе данных, а не на абстрактных микротестах.

## FAQ

### Что делает parse_number() в SQLite?

Функция очищает строку от ,, $, £ и %, учитывает ведущий минус и возвращает числовое значение либо NULL, если распознать строку нельзя.

### Какой вариант оказался самым маленьким?

C-расширение заняло 14 KB. Rust-вариант с Rusqlite занял 321 KB, а с sqlite-loadable-rs — 298 KB.

### Какая реализация была самой быстрой?

Оптимизированная C-версия с Clinger’s fast path показала лучший результат во всех трёх тестах: 19 ns, 16 ns и 18 ns на строку.

### Почему Rust сначала обогнал C?

В исследовании это связывается с тем, что Rust-эквивалент C-функции strtod оказался примерно вдвое быстрее.

### Нужно ли всегда выбирать C для SQLite-расширения?

Нет. Результат относится к конкретной функции и реализациям. Выбор стоит делать по собственным данным, требованиям к размеру артефакта и стоимости поддержки кода.

## Источники

- Simon Willison: parse_number() as a SQLite extension: C vs Rust
