Современные альтернативы: Rust, Zig, Go — когда уходить с C и когда оставаться
Двенадцать предыдущих статей трека были про то, как устроен C и что происходит под высокоуровневыми языками: указатели, куча, компоновка, системные вызовы, соглашения о вызовах. Эта — про решение, которое рано или поздно принимает каждый, кто дошёл до конца: писать следующий модуль снова на C или нет.
Вопрос «какой язык лучше» бессмысленен, и мы им заниматься не будем. Осмысленный вопрос звучит так: какие свойства C вам нужны, какие вы терпите вынужденно, и есть ли инструмент, который сохраняет первые и убирает вторые при приемлемых издержках перехода. Ответ зависит от домена, от возраста кодовой базы, от целевой платформы и от того, кого вы можете нанять — и в разных проектах одной компании он разный.
Цифры, из-за которых разговор перестал быть вкусовым, мы уже приводили в статье про неопределённое поведение: около 70% исправленных уязвимостей и в Microsoft, и в Chromium — ошибки безопасности памяти. У этой статистики есть продолжение, которое важнее самой статистики: отчёт Google по Android за 2024 год показывает, что доля таких уязвимостей упала с 76% до 24% за пять лет, причём без переписывания старого кода — просто потому, что новый код писался на безопасном по памяти языке. Это меняет постановку задачи: спорить надо не про «переписать всё», а про то, на чём писать следующие 50 тысяч строк.
Сначала разложите «C» на части
«Уйти с C» — слишком крупная формулировка, чтобы что-то значить. В реальном проекте C выполняет минимум пять разных ролей, и мигрировать их можно по отдельности.
| Роль C | Что именно она даёт | Заменяется ли отдельно |
|---|---|---|
| Язык | синтаксис, семантика, модель памяти | да — это и есть предмет спора |
| ABI | System V AMD64 / AAPCS: как передаются аргументы, как раскладываются структуры | нет и не нужно: C-ABI — это lingua franca, её говорят все |
| libc | malloc, stdio, POSIX-обёртки, локали | частично: musl, свой аллокатор, прямые syscall |
| Система сборки | Make, CMake, Autotools, компоновщик | да, и часто первым — это самый дешёвый выигрыш |
| Экосистема и культура | код ядра, драйверов, кодеков; MISRA; ревью | медленнее всего |
Ключевой пункт — второй. Ни один из «конкурентов» C не пытается заменить C-ABI: Rust, Zig, Go, Swift, C# и Python общаются с миром именно через неё. Поэтому переход почти никогда не выглядит как «перепишем проект»; он выглядит как «поставим новый модуль за границей C-ABI и будем расширять его долю». Это делает решение обратимым, а обратимые решения принимают быстро.
Оси сравнения, которые действительно решают
Опыт показывает: споры о языках заходят в тупик, когда сравнивают «удобство». Сравнивать надо свойства, которые проверяются и стоят денег.
1. Что гарантирует компилятор. У C гарантий нет: он вправе предположить, что UB не происходит, и оптимизировать исходя из этого. У Rust безопасное подмножество исключает висячие указатели, выход за границы, гонки данных и двойное освобождение — но только вне блоков unsafe. У Zig язык не проверяет времена жизни, зато в режимах Debug и ReleaseSafe встроены проверки индексов, переполнений и невалидных приведений. У Go память безопасна, но гонки данных на многословных значениях (интерфейсы, срезы, строки) способны сломать инварианты рантайма.
2. Сколько весит рантайм. Ключевой параметр для прошивок, ядер и обработчиков прерываний. Здесь между Rust/Zig и Go пропасть, и она не сокращается.
3. Модель ошибок. Коды возврата и errno в C; Result и ? в Rust; error unions и try/errdefer в Zig; пара (value, error) в Go. Разница не косметическая: в трёх последних языках забытая проверка ошибки — это ошибка компиляции или предупреждение статического анализа, а не молчаливое продолжение работы.
4. Дистрибуция и кросс-компиляция. Критерий, о котором вспоминают последним, хотя решает он больше остальных: здесь Zig и Go неожиданно сильнее Rust, а Rust сильнее C. 5. Зрелость и стоимость команды. Стабильность языка, наличие тулчейна под ваш таргет, срок выхода нового человека на скорость, возможность нанять.
Обратите внимание на левый нижний квадрант: C там один, и это не случайность. Все остальные языки чем-то платят за то, чтобы из него выйти — либо дисциплиной типов (Rust, Ada), либо рантаймом (Go, JVM), либо частичными проверками (Zig).
Rust: владение вынесено в систему типов
Одна идея Rust важнее всех остальных: время жизни объекта — часть его типа, а не комментарий в заголовочном файле. У каждого значения ровно один владелец; ссылки на него бывают либо одной изменяемой, либо любым числом неизменяемых, но не одновременно; ссылка не может пережить владельца. Это проверяется до генерации кода и стоит на исполнении ноль.
Начнём с ошибки, которую мы разбирали в динамической памяти — использование после освобождения через инвалидацию указателя при росте массива:
// uaf.c — компилируется без единого предупреждения даже с -Wall -Wextra
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int *a = malloc(3 * sizeof *a);
a[0] = 1; a[1] = 2; a[2] = 3;
int *first = &a[0]; // указатель внутрь буфера
int *bigger = realloc(a, 64 * sizeof *a); // блок мог переехать
if (!bigger) return 1;
printf("%d\n", *first); // use-after-free: читаем по старому адресу
free(bigger);
}
$ gcc -O2 -Wall -Wextra -o uaf uaf.c && ./uaf # напечатает 1 — «работает»
$ gcc -O1 -g -fsanitize=address -o uaf uaf.c && ./uaf
==...==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
Тот же смысл на Rust не компилируется:
fn main() {
let mut a = vec![1, 2, 3];
let first = &a[0]; // неизменяемое заимствование живёт до последнего использования
a.push(4); // push требует &mut self — конфликт с живым заимствованием
println!("{first}"); // ошибка: cannot borrow `a` as mutable because it is
} // also borrowed as immutable [E0502]
Разница не в том, что Rust «поймал ошибку». Разница в том, когда он её поймал: до запуска, без тестового покрытия этой строки, без санитайзера и без того, чтобы ошибка вообще попала в коммит. Санитайзер из статьи про отладку находит только то, что исполнилось; система типов проверяет все пути сразу.
Чего Rust не даёт
Список ограничений стоит знать наизусть, иначе покупаешь не тот товар:
- Утечки и дедлоки безопасны.
Box::leak,mem::forget, цикл изRc— утечка не считается нарушением безопасности; гонки данных исключены, а гонки состояний и взаимные блокировки — нет. - Переполнение целых определено, но не запрещено: паника в отладочной сборке, оборачивание в релизной. Логическую ошибку это не отменяет — нужны
checked_*/saturating_*. unsafeвозвращает всё обратно. Внутриunsafeдействуют примерно те же правила, что в C, плюс более строгие требования к алиасингу. Ошибки вunsafeразбирают через Miri — интерпретатор, ловящий нарушения модели памяти.- Стабильного ABI у языка нет. Раскладка
repr(Rust)-типов не гарантирована между версиями компилятора; наружу отдают только#[repr(C)]. - Время сборки. Мономорфизация обобщений и LLVM дают минуты на средних проектах; это заметная часть цены.
Граница с C: как выглядит миграция на самом деле
Rust спроектирован под сосуществование с C, и это его главное практическое преимущество перед «языками мечты». Направление внутрь — bindgen читает заголовочные файлы и генерирует объявления; направление наружу — cbindgen генерирует заголовок из Rust-кода.
// Вызов существующей C-библиотеки: небезопасные объявления прячем за safe-обёрткой.
use std::ffi::{c_char, c_int, c_void, CStr};
unsafe extern "C" { // в edition 2024 блок extern сам помечается unsafe
fn buf_new(cap: usize) -> *mut c_void;
fn buf_push(b: *mut c_void, s: *const c_char) -> c_int;
fn buf_free(b: *mut c_void);
}
/// Владелец C-объекта. Время жизни выражено типом, а не соглашением в README.
pub struct Buf { raw: *mut c_void }
impl Buf {
pub fn with_capacity(cap: usize) -> Option<Self> {
let raw = unsafe { buf_new(cap) };
if raw.is_null() { None } else { Some(Buf { raw }) }
}
pub fn push(&mut self, s: &CStr) -> Result<(), i32> {
// единственное место, где мы отвечаем за корректность вручную
let rc = unsafe { buf_push(self.raw, s.as_ptr()) };
if rc == 0 { Ok(()) } else { Err(rc) }
}
}
impl Drop for Buf {
fn drop(&mut self) {
unsafe { buf_free(self.raw) } // free вызывается ровно один раз, всегда
}
}
Обратное направление — Rust как ускоритель или как безопасная замена одного модуля внутри C-приложения:
// Отдаём наружу владение буфером — вместе с парной функцией освобождения.
#[repr(C)]
pub struct Blob { ptr: *mut u8, len: usize, cap: usize }
#[unsafe(no_mangle)] // edition 2024: атрибут явно небезопасен
pub extern "C" fn rs_compress(src: *const u8, n: usize) -> Blob {
let input = unsafe { std::slice::from_raw_parts(src, n) };
// паника через extern "C" начиная с Rust 1.81 гарантированно завершает процесс,
// поэтому всё, что может паниковать, ловим здесь и превращаем в пустой Blob
let out = std::panic::catch_unwind(|| compress(input)).unwrap_or_default();
let mut v = std::mem::ManuallyDrop::new(out);
Blob { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() }
}
#[unsafe(no_mangle)]
pub extern "C" fn rs_blob_free(b: Blob) {
if !b.ptr.is_null() {
unsafe { drop(Vec::from_raw_parts(b.ptr, b.len, b.cap)) }
}
}
# Собираем статическую библиотеку с C-ABI и линкуем в обычный C-проект
$ cargo build --release # [lib] crate-type = ["staticlib"] в Cargo.toml
$ cbindgen --config cbindgen.toml --crate mylib --output mylib.h
$ gcc -O2 main.c -o app target/release/libmylib.a -lpthread -ldl -lm
Правило, которое стоит вывесить над столом: указатель может пересекать границу сколько угодно раз, право освобождать — ни разу. Память, выделенную malloc, освобождает C; память из Rust-аллокатора — Rust. Смешение двух куч даёт повреждение метаданных, падающее позже и совсем в другом месте.
Про сам язык — модель владения, лайфтаймы, трейты, асинхронность и unsafe — подробно в отдельном треке: владение и заимствование, времена жизни, unsafe и FFI, конкурентность.
Zig: тот же уровень контроля, но без сюрпризов
Zig отвечает на другую жалобу на C. Не «в нём легко ошибиться с памятью», а «в нём слишком много скрытого и слишком мало выразительности»: препроцессор как отдельный недоязык, отсутствие обобщений, макросы, скрытые аллокации в чужих библиотеках, неявные приведения типов.
Три решения определяют язык:
Аллокатор — обычный параметр. В стандартной библиотеке Zig нет функции, которая выделяет память сама. Если функция выделяет — она принимает Allocator. Это делает тестирование на утечки тривиальным и радикально упрощает жизнь во встраиваемых системах, где глобальной кучи может не быть вовсе.
comptime вместо препроцессора и шаблонов. Обобщения, вычисление констант, генерация кода и рефлексия — это один механизм: выполнение обычного Zig-кода на этапе компиляции. Типы — значения типа type.
Никакого скрытого потока управления. Нет перегрузки операторов, нет исключений, нет деструкторов, нет неявных вызовов. Строка a + b — это сложение, а f() — единственный вызов.
const std = @import("std");
// Обобщение — это функция от типа. Никакого отдельного синтаксиса шаблонов.
fn Stack(comptime T: type) type {
return struct {
items: []T,
len: usize = 0,
alloc: std.mem.Allocator,
const Self = @This();
pub fn init(alloc: std.mem.Allocator, cap: usize) !Self {
return .{ .items = try alloc.alloc(T, cap), .alloc = alloc };
}
pub fn deinit(self: *Self) void {
self.alloc.free(self.items); // освобождает тот, кто выделил
}
pub fn push(self: *Self, v: T) !void {
if (self.len == self.items.len) {
self.items = try self.alloc.realloc(self.items, self.items.len * 2);
}
self.items[self.len] = v; // индекс проверяется в Debug и ReleaseSafe
self.len += 1;
}
};
}
pub fn main() !void {
// DebugAllocator (до 0.14 назывался GeneralPurposeAllocator) сам ловит
// утечки, двойное освобождение и запись за границей блока.
var dbg = std.heap.DebugAllocator(.{}){};
defer if (dbg.deinit() == .leak) std.debug.print("обнаружена утечка\n", .{});
var s = try Stack(u32).init(dbg.allocator(), 4);
defer s.deinit(); // выполнится при любом выходе из области
for (0..10) |i| try s.push(@intCast(i));
std.debug.print("len = {d}\n", .{s.len});
}
$ zig build-exe stack.zig # режим Debug: все проверки включены
$ zig build-exe stack.zig -O ReleaseSafe # проверки остаются, оптимизация включена
$ zig build-exe stack.zig -O ReleaseFast # проверки снимаются: снова территория UB
Разбор режимов сборки здесь важнее синтаксиса. Debug и ReleaseSafe вставляют проверки индексов, знакового и беззнакового переполнения, приведений с потерей и разыменования пустого optional; нарушение даёт панику с трассировкой, а не тихую порчу памяти. ReleaseFast и ReleaseSmall эти проверки убирают — и там нарушения снова становятся неопределённым поведением ровно в том смысле, который мы разбирали в статье про UB. Практика: держите ReleaseSafe по умолчанию и снимайте проверки точечно там, где профилировщик показал стоимость.
Чего Zig не даёт: проверки времён жизни. Use-after-free в Zig компилируется. Ловится он не языком, а инструментами — отладочным аллокатором, который не возвращает страницы системе и распознаёт повторное использование, тем же ASan через zig cc, тестами.
Zig как инструмент для C-проекта, даже если вы не пишете на Zig
Самая недооценённая часть: в дистрибутиве Zig лежит clang, заголовки нескольких libc (glibc разных версий, musl, mingw) и линковщик. Отсюда — zig cc, кросс-компилятор для C, который работает из коробки и умещается в один архив:
# Кросс-сборка C-проекта из Linux под три платформы без единого тулчейна в системе
$ zig cc -target aarch64-linux-musl -O2 -o app-arm64 main.c
$ zig cc -target x86_64-windows-gnu -O2 -o app.exe main.c
$ zig cc -target x86_64-linux-gnu.2.28 -O2 -o app-old main.c # привязка к версии glibc
$ CC="zig cc" ./configure && make # подставляется в Autotools
Последняя строка решает боль, знакомую по сборке и компоновке: сборка под старую glibc без контейнера со старым дистрибутивом. Zig можно взять как систему сборки, оставив весь код на C, — обратимый шаг с нулевым риском для кода.
Цена Zig очевидна и её надо называть прямо: язык до версии 1.0. Ломающие изменения в каждом релизе, переработанный ввод-вывод в 0.15, изъятая и переделываемая асинхронность, небольшая экосистема пакетов, мало кандидатов на рынке. Для внутреннего инструмента или нового продукта, где команда готова обновлять код под релизы, это приемлемо; для десятилетней поддержки в регулируемой отрасли — обычно нет.
Go: когда «системное» на самом деле означает «сетевое»
Go в этом списке стоит особняком, и путаница здесь стоит дорого. Go — не замена C для драйверов и прошивок: у него сборщик мусора, планировщик горутин и рантайм, который нельзя выключить. Зато Go — очень хорошая замена C там, где на C писали не от хорошей жизни: демоны, агенты, прокси, CLI-инструменты, всё, что раньше было «сетевой сервис на C с epoll и пулом потоков».
Что Go меняет по сравнению с C в этом классе задач:
- Память безопасна без вашего участия — ценой GC. Сборщик конкурентный, неперемещающий, паузы «стоп-мир» субмиллисекундные; подробности — в докладе Getting to Go и руководстве по GC.
- Конкурентность на порядок дешевле в написании. Горутина стоит килобайты, а не мегабайт стека; вместо ручной работы с
pthread_mutex_tиз статьи о потоках — каналы иgo. Детали — в конкурентности Go. - Дистрибуция тривиальна: один статический бинарник, кросс-компиляция переменными окружения, сборка секунды вместо минут.
Что вы теряете: контроль над раскладкой памяти (ограниченно, но всё же), предсказуемость хвостовых задержек под жёсткими SLA, возможность работать без кучи, и — главное — дешёвый вызов C.
# Кросс-компиляция и сборка без cgo: получается статический бинарник
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o agent ./cmd/agent
$ go build -gcflags='-m' ./... # что убежало в кучу: анализ побегов
$ go build -race ./... && ./app # детектор гонок поверх ThreadSanitizer
Цена границы cgo
Если Go вызывает C, каждый вызов — не просто call. Горутина исполняется на стеке в несколько килобайт, который рантайм умеет наращивать копированием; C так нельзя, поэтому вызов уходит на системный стек, а планировщик должен отпустить исполняющий поток, чтобы блокировка в C не заморозила остальные горутины.
переданная память должна оставаться живой C-->>OS: возврат значения OS-->>R: возврат на стек горутины, перепривязка P R-->>G: результат Note over R,G: суммарно — десятки наносекунд против единиц у обычного вызова Go
Практические следствия, о которые ошибаются чаще всего:
/*
#include <stdlib.h>
#include <string.h>
*/
import "C"
import "unsafe"
// Правило cgo: Go-указатель, ведущий на память с другими Go-указателями,
// передавать в C нельзя — GC не отслеживает ссылки, живущие в чужой куче.
func upper(s string) string {
cs := C.CString(s) // malloc + копия: С-строка нужна с NUL
defer C.free(unsafe.Pointer(cs)) // освобождает тот, кто выделил
C.strupr_compat(cs) // работа на стороне C
return C.GoString(cs) // копия обратно в кучу Go
}
Две копии на вызов и переключение стека — вот почему совет «выносите горячие циклы в C» в Go обычно не работает: выигрыш съедается границей. Правильная стратегия — делать границу редкой и толстой: один вызов, обрабатывающий пакет данных, вместо тысячи мелких. Классический разбор — «cgo is not Go» Дэйва Чейни. Плюс cgo отключает кросс-компиляцию по умолчанию и требует C-тулчейна для каждой цели — то есть отдаёт главное преимущество Go.
Остальное поле
| Язык | Ниша, где он выигрывает у C | Чем платите |
|---|---|---|
| C++ | огромная кодовая база уже есть; RAII и шаблоны без рантайма — см. статью 12 | UB никуда не делся, сложность языка, время сборки |
| Ada / SPARK | авионика, ЖД, оборона: контрактное проектирование и формальные доказательства | узкий рынок труда, дорогие тулчейны |
| Swift (Embedded) | Apple-платформы и, с 2024 года, микроконтроллеры без рантайма | экосистема сосредоточена вокруг одного вендора |
| C# с NativeAOT | десктоп и сервисы, где нужен нативный старт без JIT | GC остаётся, размер рантайма |
| Java с Panama (FFM API) | доступ к нативным библиотекам без JNI из существующего Java-стека | GC, стартовое время, footprint |
| Nim, Odin, D, Carbon | точечные удобства: синтаксис, метапрограммирование, миграция с C++ | малые сообщества, риск непрерывности |
Общий принцип отбора: язык годится в системный проект, если у него есть (а) компилятор для вашего таргета, (б) режим без сборщика мусора или с предсказуемыми паузами, (в) способ говорить на C-ABI, (г) кто-то, кто будет чинить компилятор через пять лет.
Дерево решения
системного уровня"] --> B{"Нужен ли жёсткий
реальный времени или
работа без кучи?"} B -->|"да"| C{"Есть ли компилятор
под этот таргет?"} B -->|"нет"| D{"Это сетевой сервис,
агент или CLI?"} C -->|"только вендорский C"| E["Остаёмся на C.
Закалка: MISRA, статанализ,
санитайзеры на хосте"] C -->|"есть Rust или Zig"| F{"Требуется ли
сертификация?"} F -->|"да"| G["Rust с квалифицированным
тулчейном (Ferrocene)
или Ada/SPARK"] F -->|"нет"| H["Rust — если важны гарантии;
Zig — если важны простота
и контроль над сборкой"] D -->|"да"| I{"Есть ли жёсткий бюджет
по хвостовым задержкам
или по памяти?"} D -->|"нет"| J{"Расширяем существующую
C-кодовую базу?"} I -->|"нет"| K["Go: скорость разработки,
один бинарник, простой найм"] I -->|"да"| L["Rust: без GC,
предсказуемый хвост"] J -->|"да"| M["Новый код за границей C-ABI:
Rust (staticlib + cbindgen)
или Zig (@cImport)"] J -->|"нет"| N["Свободный выбор:
решает команда и экосистема"] style E stroke-width:2px style M stroke-width:2px
Обратите внимание, к чему сходятся ветки. Ответ «остаёмся на C» появляется не потому, что C лучше, а потому, что нет компилятора под таргет или требуется сертифицированный тулчейн. Это проверяемые факты, а не предпочтения — и именно так и надо формулировать решение в проектной документации.
Стратегия миграции: модуль, а не проект
Единственная стратегия с приемлемым риском выглядит так: не трогать работающий C, а вводить новый язык за границей, которая и так существует. Жизненный цикл одного модуля:
Ключевой этап — дифференциальный фаззинг: обе реализации получают один вход, результаты сравниваются, расхождение считается падением. Он ловит то, чего не ловят обычные тесты, — молчаливые различия в обработке граничных случаев (пустой вход, максимальная длина, невалидный UTF-8, переполнение счётчика).
Живые примеры такого подхода, на которые можно ссылаться в разговоре с руководством: Prossimo от ISRG переписал TLS-стек (rustls), NTP-демон (ntpd-rs) и sudo (sudo-rs) — не «всю систему», а отдельные компоненты с большой поверхностью атаки. Curl поддерживает rustls как один из TLS-бэкендов, сохраняя основной код на C. Rust for Linux живёт в ядре с версии 6.1 — с абстракциями над C-API ядра, а не с переписыванием подсистем.
Что померить до решения, а не после
Решение должно опираться на числа вашего проекта, а не на бенчмарки из интернета:
$ size ./app && nm -C --size-sort -S ./app | tail -20 # что тянет за собой рантайм
$ /usr/bin/time -v ./app ... # пиковый RSS под нагрузкой, а не в покое
$ ./bench --histogram # p99 и p99.9: среднее ничего не скажет о паузах GC
$ cargo build --release --timings # цена итерации: холодная и инкрементальная сборка
$ perf stat -e cycles,instructions ./bench-ffi # стоимость границы, если она будет
Отдельная строка, которую часто забывают: сколько времени уйдёт у команды. Реалистичная оценка для инженера, знающего C: продуктивность на Rust — недели до первых задач и месяцы до комфорта с лайфтаймами и асинхронностью; на Zig — дни, потому что язык маленький, но плюс постоянные правки под новые релизы; на Go — дни.
Когда оставаться на C — честный список
Это не список утешений. Это ситуации, где переход объективно проигрывает.
Нет компилятора под таргет. DSP, экзотические микроконтроллеры, вендорские тулчейны (TI, Microchip, Renesas), где официально поддерживается только C конкретной версии. LLVM-бэкенда нет — разговор окончен. Смежная тема — встраиваемое программирование.
Сертификация и нормативные требования. DO-178C в авиации, ISO 26262 в автомобилях, IEC 62304 в медтехнике требуют квалифицированного тулчейна и трассируемости. Для C такие компиляторы существуют десятилетиями; для Rust они появились недавно (Ferrocene) и не покрывают всех комбинаций; для Zig их нет.
Вы и есть тот самый C-интерфейс. Библиотека, чей публичный API — заголовочный файл, встраиваемый в чужие проекты (zlib, SQLite, libpng): вы можете сменить язык реализации, но не язык интерфейса. Если реализация небольшая и стабильная — смена языка добавляет зависимостей больше, чем убирает рисков.
Кодовой базе тридцать лет и она работает. Плотность уязвимостей падает с возрастом кода: старый оттестированный код содержит меньше ошибок на строку, чем свежий. Переписывание переносит зрелый код в состояние «новый», то есть увеличивает число дефектов в моменте — это и есть главный вывод из данных Google по Android.
Команда и ресурсы. Пять человек, знающих C и предметную область, лучше двух, знающих Rust: смена языка без плана обучения теряет и то и другое. Сюда же — прошивка на 32 КБ флеша, загрузчик, обработчик прерываний: C здесь по-прежнему естественный выбор, хотя no_std Rust и Zig в эту нишу заходят.
Оставаться на C — нормальное инженерное решение, если оно сопровождается дисциплиной из статьи об UB: санитайзеры в отладочной сборке, _FORTIFY_SOURCE=3 и закалка в релизной, статический анализ и непрерывный фаззинг в CI, размерные API вместо голых указателей.
Типичные ошибки при выборе
| Ошибка | Почему это ошибка |
|---|---|
| «Rust безопасный, значит, ошибок не будет» | безопасность памяти ≠ корректность; дедлоки, логика и unsafe остаются вашими |
| «Перепишем всё за квартал» | переписывание возвращает зрелый код в состояние нового; риск растёт, а не падает |
| «Go — системный язык, напишем драйвер» | GC и рантайм несовместимы с обработчиком прерываний и жёстким реальным временем |
| «Вынесем горячий код из Go в C» | стоимость границы cgo часто превышает выигрыш; сначала измерьте |
| «Zig почти готов, подождём 1.0» | ждать можно годами; либо принимаете ломающие изменения, либо не берёте |
| «Выберем язык, потом разберёмся со сборкой» | кросс-компиляция, реализация libc и упаковка решают больше споров, чем синтаксис |
| «У нас C, значит, нам не подходит ничего» | граница C-ABI существует всегда; вопрос только в том, что стоит по другую её сторону |
Мини-итог
Про постановку задачи. Мигрируют не язык, а роли: код, сборку, интерфейс. C-ABI не заменяется и не должна — она и есть точка, где сосуществуют языки.
Про выбор конкретного инструмента. Rust — когда нужны проверяемые гарантии без рантайма и вы готовы платить сложностью и временем сборки. Zig — когда нужен контроль уровня C с современной эргономикой и вы принимаете статус pre-1.0; отдельно — как кросс-компилятор для существующего C, шаг с нулевым риском. Go — когда «системное» означает сетевые сервисы и инструменты, а GC-паузы укладываются в бюджет. C — когда нет компилятора под таргет, нужна сертификация, кодовая база зрелая или интерфейс обязан быть заголовочным файлом.
Про процесс. Новый код на безопасном языке даёт основной выигрыш; переписывание старого — почти никогда. Границу вводите там, где она уже есть; закрывайте её парными функциями создания и освобождения; проверяйте дифференциальным фаззингом; переключайте флагом с возможностью откатиться.
Про C. Даже если вы уходите — знание из этого трека не устаревает. Владение в Rust, аллокаторы в Zig, побеги в куче в Go, стоимость границы FFI, выравнивание структур, содержимое objdump — всё это понимают только те, кто прошёл через указатели, стек и компоновку.
Источники
- Google Security Blog, Eliminating Memory Safety Vulnerabilities at the Source — данные Android и главный аргумент про новый код.
- Rust in the Android platform — как это начиналось на практике.
- CISA, The Case for Memory Safe Roadmaps и NSA, Software Memory Safety.
- The Rustonomicon — правила
unsafeи FFI; Miri — проверка модели памяти; rust-bindgen и cbindgen — генерация связок в обе стороны. - Rust for Linux — абстракции над C-API ядра; Ferrocene — квалифицированный тулчейн; SPARK — формальная верификация в Ada.
- Документация Zig и Why Zig When There is Already C++, D, and Rust?.
- Эндрю Келли, zig cc: a Powerful Drop-In Replacement for GCC/Clang.
- Go GC Guide и Getting to Go: The Journey of Go’s Garbage Collector.
- Документация cgo, «cgo is not Go» и Prossimo — rustls, sudo-rs, ntpd-rs как образцы точечной замены.
- Платформы, поддерживаемые rustc — проверять до, а не после решения.
Что дальше
Трек закончен: от обзора и основ языка через память, сборку, процессы и ассемблер до этого разговора о выборе инструмента. Дальше расходятся несколько дорог, и все они опираются на то, что вы уже знаете.
- Вглубь того же слоя. Операционные системы — что находится по другую сторону системного вызова; отдельно память в ядре и написание своей ОС.
- Ближе к железу. Встраиваемые системы — тот же C, но с регистрами периферии, прерываниями и бюджетом в килобайтах.
- Как устроен компилятор, который вы столько раз ругали. Компиляторы, особенно оптимизации и сборка мусора. Языки из этой статьи целиком — Rust и Go.
- Смежные дисциплины. Производительность — измерять, а не догадываться; безопасность — безопасная разработка как процесс; фундамент CS — если хочется закрыть пробелы снизу.
А чтобы выбрать порядок и не распыляться — общая карта портала: дорожная карта обучения.