Полгода назад я писал заметку о выборе языка для нового проекта и там указал, что не рассматривал Rust. Первое знакомство с Rust у меня было несколько лет назад, когда я на нем пытался написать драйвер для фискального регистратора (ФР) и подход естественно тогда был как замена С++: прямая работа с указателями, битовая арифметика и прочее. С тех пор у меня было убеждение, что Rust – это для системного низкоуровневого программирования.

В общем выбрал я Go и первое время все меня устраивало в нем: быстрая разработка, хорошая читаемость (через пол года можно взглянуть на код и сразу понять про что он), хорошо документированные библиотеки. По сути пишешь логику приложения, а не “борешься” с языком/платформой. Но с ростом проекта стало не хватать возможности делать более абстрактный код, а основной проблемой, из-за которой я вернулся опять к выбору языка, стала обработка ошибок в Go.

Что бы было понято в чем проблема, вот пример обработчика для fiber:

func sync(c *fiber.Ctx) error {
	userData := c.Locals("user")
	if userData == nil {
		return c.SendStatus(fiber.StatusUnauthorized)
	}
	user := userData.(auth.User)

	changedDaysIn := make([]syncData, 0, 10)
	if err := c.BodyParser(&changedDaysIn); err != nil {
		slog.Error(err.Error())
		return c.SendStatus(fiber.StatusBadRequest)
	}

	for i := range changedDaysIn {
		dayIn := &changedDaysIn[i]

		if dayIn.UserID != user.UserID {
			continue
		}

		//получаем день из базы
		dayInDb, err := repo.GetEstimatyByDay(user.UserID, dayIn.Date)
		if errors.Is(err, gorm.ErrRecordNotFound) {
			err = repo.AddEstimatesOfDays(&dayIn.EstimatesOfDays)
		} else if err != nil {
			slog.Error(err.Error())
			return c.SendStatus(fiber.StatusInternalServerError)
		} else {
			changedDay, err := repo.GetChangedDay(user.UserID, dayIn.Date)
			if errors.Is(err, gorm.ErrRecordNotFound) {
				err = repo.UpdateEstimatyOfDay(&dayInDb)
			} else if err != nil {
				slog.Error(err.Error())
				return c.SendStatus(fiber.StatusInternalServerError)
			} else if changedDay.ChangeTime.Before(dayIn.ChangeTime) {
				err = repo.UpdateEstimatyOfDay(&dayInDb)
				if err != nil {
					slog.Error(err.Error())
					return c.SendStatus(fiber.StatusInternalServerError)
				}
				err = repo.DeleteChangedDay(dayIn.UserID, dayIn.Date)
				if err != nil {
					slog.Error(err.Error())
					return c.SendStatus(fiber.StatusInternalServerError)
				}
			}
		}

	}

	changedDays, err := repo.GetEntitiesByUser[repo.ChangedDay](user.UserID)
	if err != nil {
		slog.Error(err.Error())
		return c.SendStatus(fiber.StatusInternalServerError)
	}

	result := make([]syncData, 0, len(changedDays))
	for i := range changedDays {
		estimate, err := repo.GetEstimatyByDay(user.UserID, changedDays[i].Date)
		if err != nil {
			slog.Error(err.Error())
			return c.SendStatus(fiber.StatusInternalServerError)
		}

		result = append(result, syncData{
			EstimatesOfDays: estimate,
			ChangeTime:      time.Now(),
		})

		err = repo.DeleteChangedDay(user.UserID, changedDays[i].Date)
		if err != nil {
			slog.Error(err.Error())
			return c.SendStatus(fiber.StatusInternalServerError)
		}
	}

	return c.JSON(result)

}

В данной маленькой функции аж 9 обработок ошибок через if err!=nil и они сильно “мусорят” логику. Если бы убрали эти обработки ошибок то получили бы более читаемый код. И в этом коде еще нет проверок указателей на nil…

В последнее время мне на Хабре попадались статьи по Rust и я решил еще раз рассмотреть его, но с точки зрения прикладного программирования. И больше всего вдохновила меня статья Rust Vs Go: A Hands-On Comparison. В ней показано, что код на Rust зачастую проще и короче чем на Go.

Вот тот же алгоритм на Rust. Кода меньше, абстракций больше.

async fn sync(
    claims: Claims,
    State(repo): State<Arc<Repository>>,
    Json(payload): Json<Vec<SyncData>>,
) -> Result<impl IntoResponse, PgError> {
    //Приоритет сервера. Если нет дня на сервере, то создаем. Если есть на сервере
    //то проверяем таблицу Sync. Если дата изменения на сервере меньше даты изменения на сервере
    //то обновляем.

    let estimates = Entity::<Estimate>::new(repo.clone());
    let changed_days = Entity::<ChangedDay>::new(repo.clone());

    for data in payload {
        if data.estimate.user_id != user_id {
            continue;
        }
        match changed_days.get_by_day(user_id, data.estimate.date).await {
            Ok(changed_day) if changed_day.change_time < data.change_time => {
                estimates.upsert(user_id, data.estimate).await?;
                changed_days.delete(user_id, changed_day.id).await?;
            }
            Err(PgError::NotFound) => {
                estimates.upsert(user_id, data.estimate).await?;
            }

            Err(err) => return Err(err),
            _ => (),
        }
    }

    let mut result: Vec<SyncData> = Vec::new();
    let changed_days_data = changed_days.get_all(user_id).await?;

    for day in changed_days_data {
        let estimate = estimates.get_by_day(user_id, day.date).await?;

        result.push(SyncData {
            estimate,
            heart_status: HeartStatus::default(),
            change_time: day.change_time,
        });

        changed_days.delete(user_id, day.id).await?;
    }

    Ok(result)
}

Для понимая языка решил сразу просто переписать сервис Go на Rust. Вместе с изучением языка это заняло около месяца. (а сейчас бы это у меня заняло несколько дней). И по итогу могу сказать, что Rust мне понравился больше, чем Go. Он решил “проблему” с обработкой ошибок и дал инструменты, позволяющие писать более абстрактный код (generics и macros), правда ценой некоторого ухудшения понимания кода, когда к нему возвращаешься после длительного перерыва.

В целом мне кажется концептуально языки Go и Rust похожи. Для меня вообще Rust представляется как улучшения версия Go. Что же у них общего? Это:

  • парадигма struct + trait / interface
  • generics
  • функции первого порядка / лямбды
  • каналы для обмена между потоками
  • простота подключения внешних зависимостей (пакетов)
  • Компиляция в нативный код (в том числе со статической линковой)

И Rust к этому добавляет:

  • Проброс ошибок с помощью оператора ‘?’, а так же типы Result и Options
  • Переопределение ряда операторов, т.к. это трейты.
  • Возможность реализовывать свои трейты (интерфейсы) для внешних типов (например для встроенных)
  • enums содержащие значения
  • мутабельные, иммутабельные и static переменные
  • Контроль того, где будет размещаться значение: на стеке или в куче (Box<>)
  • Более мощную систему generics
  • lazy итераторы (в функциональном стиле)
  • Выполнение кода в момент компиляции (макросы/атрибуты и static context)
  • Контроль памяти на этапе компиляции, а не с использованием GC (это я отношу в плюсы Rust)
  • Намного меньший размер скомпилированных файлов (что важно например для wasm)
  • Нормальная работа с указателями (правда через unsafe)
  • Нормальный FFI (в отличии от cgo в go)

Из-за большей гибкости Rust у него больше библиотек и они более функциональные. Например библиотека для сериализации/десериализации serde.

Основных преимуществ у Rust два:

  • нет GC
  • Нет утечек памяти, ссылок на не инициализированную память (естественно в safe). В Rust ссылки всегда ссылаются на существующее значение (в safe) и поэтому проверки на null не нужны. (данная проблема в Go описана в этой статье). Это автоматически закрывает кучу уязвимостей и предотвращает множество ошибок в рантайме. (а следовательно экономит очень много денег).

У Rust так же есть и минусы, в основном связанные с lifetime (пример). Но сложные проблемы с lifetime решаются через использование Rc/Arc.

Так же в Rust сложно сразу построить правильную архитектуру, нет устоявшихся практик. Программисты пытаются в Rust воссоздать подходы из ОПП, но это сложно.

Например:

В sqlx пул соединений/транзакция передается непосредственно в метод (.fetch_one(&conn)). Писать одно и тоже при каждом вызове метода, да еще и тянуть зависимости через множество методов не хотелось, решил сделать структуру содержащую поле conn которое может быть или пулом или соединением и имплементировать для этой структуры методы CRUD. Так вот разработчик sqlx для трэйта Executor (который передается в методы работы с БД) указал ограничение Sized и из-за этого его нельзя использовать как трейт-объект. И поэтому нельзя создать такую структуру, пришлось писать отдельные методы для пула и отдельно для поддержки транзакций. Это пример того, как архитектура используемой библиотеки накладывает ограничение и кода приходится писать больше + теряется время на попытки реализации задуманного. В Go бы я без проблем передал интерфейс Executor и, если нужно, в рантайме, в зависимости от объекта интерфейса, сделал то что нужно.

Правда хочу отметить, что бизнес выбирает Go, т.к. на нем больше разработчиков и ниже порог входа. Но ИМХО это промежуточный этап. Те кто начал писать на Go в итоге перейдут на Rust.

Разработчики должны стремится к минимизации суммы следующих показателей (в относительных величинах, а не абсолютных):

  • использование памяти
  • использование процессора
  • скорость написания ПО (скорость выкатывания новых фич)
  • стоимость разработки (в части стоимости разработчиков)

Rust обходит Go в использовании памяти и скорости написания ПО (в Go больше писать и больше тестировать хотя бы из-за null pointer, ссылок не на те данные и т.п.). По использованию процессора Rust быстрее (см. раздел Бонус в этой статье) . А вот по стоимости разработки сложный вопрос. На написание идентичного функционала на Go нужно затратить больше часов. При этом у Go низкий порог входа и разработчики должны быть дешевые (т.к. Go способны выучить больше человек, чем Rust). Но судя по вакансиям Go разработчики наоборот одни из самых дорогих и получается, что разработка на Go дорогая, дороже чем на Rust. Бизнес же ориентируется только на количество разработчиков на конкретном языке, а на Go их пока в России больше чем на Rust и поэтому теряет деньги на неоптимальной технологии.