Boulder Rust Meetup 발표를 바탕으로, C와 C++을 거치는 hourglass 패턴으로 Rust와 C++ 사이를 안전하게 연결하는 방법을 소개합니다. 수동 FFI, opaque 타입, Eigen/nalgebra 간 변환, CMake 통합 개요까지 다룹니다.
이 글은 제가 Boulder Rust Meetup을 위해 준비한 내용을 정리한 블로그 글입니다.
어떻게 할지 이야기하기 전에, 왜 그렇게 해야 하는지부터 이야기해 보겠습니다. 다음을 전제합니다:
이게 제가 처한 상황입니다. 업무에서 Rust를 사용하자는 제안에 대해 들은 주요한 반대는 기술적이라기보다 사회적이었습니다. 제가 들었던 몇 가지는 이렇습니다:
한 가지 언어만 쓰면 모두에게 프로젝트 접근성이 높아진다. 여러 언어를 쓰면 내가 기여하기가 어려워진다. Rust로 쓰면 이 프로젝트에 투입할 사람을 구하기가 힘들어진다.
연구는 다양성이 더 나은 결과로 이어진다고 보여줍니다. 이는 우리 프로젝트에도 이롭습니다. 이런 주장에 깔린 정서는 ‘타자’에 대한 두려움입니다. 저는 동료들의 두려움을 비판하기보다는, 이질적인 코드베이스를 구축하는 것이 어떻게 더 나은 프로젝트로 이어질 수 있는지에 초점을 맞추는 것이 도움이 되었습니다.
프로그래밍은 하나의 공예이며, 서로 다른 관점에서 함께 일할 때 결과의 품질이 향상됩니다. C++, 그 위에 구축된 프로젝트, 그리고 그것을 사용하는 프로그래머들은 분명한 가치를 지닙니다. Rust는 새로운 아이디어와 관점을 가져옵니다. 프로젝트 안에 다리를 놓는다면, 예전의 동질적인 코드베이스보다 더 나은 코드베이스를 만들 수 있습니다.

골든게이트 브리지, 샌프란시스코
소프트웨어 작성은 팀 스포츠입니다. 다양한 아이디어와 접근을 환영하여 주어진 문제에 가장 좋은 해법을 찾고자 합니다. 큰 C++ 프로젝트를 Rust로 갈아엎고 싶더라도, 일정과 팀 구성상 그럴 수 없는 경우가 대부분입니다. C++ 코드베이스가 있다면 아마 C++ 프로그래머 동료들도 있을 것이고, 다리를 놓는 편이 그들의 지지를 얻을 가능성이 더 큽니다.
C++으로부터, 그리고 C++으로의 브리지를 자동으로 만들어주는 몇 가지 잘 알려진 프로젝트가 있습니다.
Rust와 C++ 모두에서 1급처럼 느껴지는 라이브러리 타입을 인터페이스에 사용하고 싶었기 때문에, 위 도구들만으로는 제 요구를 충족하지 못했습니다. PickNik에서 우리는 로보틱스 코드를 작성하고, 많은 C++ 코드는 Eigen 타입을 사용합니다. Rust에서는 같은 개념을 표현하기 위해 nalgebra 타입을 사용하고 싶었습니다.
OptIk 프로젝트에서 저는 많은 것을 배웠습니다. 완전한 예제가 필요하면 살펴보세요.

레오나드 P. 자킴 벙커 힐 메모리얼 브리지, 보스턴
C++과의 인터롭은 고전적인 모래시계(hourglass) 접근으로 합니다. Rust 라이브러리를 C에 연결하고, 그 C 인터페이스를 안전하게 사용하는 C++ 타입을 만듭니다. 다른 언어(예: Python)와의 인터롭도 같은 방식입니다.

모래시계 패턴
C++에서 Rust 객체를 보유하여, Rust 객체를 인자로 받는 Rust 함수를 호출할 수 있어야 합니다. 이를 위해 Rust 객체를 만들고 그 포인터를 C++ 코드로 ‘누수’시켜야 합니다. 그리고 해당 포인터를 받아 그 객체를 파괴할 수 있는 Rust 함수도 포함합니다. C++ 쪽에서는 Rust 객체에 대한 불투명(opaque) 포인터를 멤버로 가지는 클래스를 만들고, 소멸자에서 이를 해제하게 하면 됩니다. 중요한 이유 중 하나는 할당자와 해제자가 반드시 짝을 이루기 때문입니다. Rust 객체를 C++ 해제자로 파괴하거나 그 반대로 하는 것은 유효하지 않습니다.
Rust 라이브러리 타입으로부터 C++ 라이브러리 타입을 만들어야 하는 경우(예: nalgebra::geometry::Isometry3로부터 Eigen::Isometry3d 만들기)에는, 메모리를 공유하지 말고 하부 데이터를 복사해야 합니다. C++에서는 라이브러리 타입을 확장하여 서로 다른 해제자를 사용해 기저 메모리 파괴를 처리하게 만들 수 없기 때문입니다.
Rust의 동차 변환(homogeneous transform) 타입 nalgebra::geometry::Isometry3의 경우, 하부 데이터는 16개의 double로 이루어진 단일 배열로 표현되는 4x4 행렬입니다. 고정 크기 배열은 FFI 경계를 넘어 전달할 수 있습니다. 이를 활용하면 추가 복사나 할당을 피할 수 있습니다.

프리몬트 브리지, 포틀랜드
C++ 빌드 시스템과 어떻게 통합할지가 과제입니다. 제 직장의 C++ 코드는 CMake를 사용하기 때문에, 다른 CMake 프로젝트가 이 C++ 프로젝트를 소비할 수 있게 만드는 예시를 링크하겠습니다.
프로젝트의 코드 구성을 위해 두 개의 Rust 크레이트(패키지)로 분리하겠습니다.
robot_joint – C++에서 사용하고 싶은 Rust 라이브러리(지금 가지고 있는 것)robot_joint-cpp – C++ 인터롭 계층(당신이 작성할 것)다음과 같은 Rust struct와 팩토리 함수를 C 인터페이스로 만들어야 합니다.
pub struct Joint {
name: String,
parent_link_to_joint_origin: Isometry3<f64>,
}
impl Joint {
pub fn new() -> Self;
}
robot_joint-cpp 쪽에 다음 내용을 담은 lib.rs를 만듭니다.
use robot_joint::Joint;
#[no_mangle]
extern "C" fn robot_joint_new() -> *mut Joint {
Box::into_raw(Box::new(Joint::new()))
}
#[no_mangle]
extern "C" fn robot_joint_free(joint: *mut Joint) {
unsafe {
drop(Box::from_raw(joint));
}
}
각 함수는 Rust의 네임 맹글링을 끄기 위한 #[no_mangle] 속성과, C 호출 규약을 지정하는 extern "C"가 필요합니다. Box::into_raw(Box::new(는 Rust 객체를 힙에 생성하고 그 포인터를 누수시키는 기법입니다. 마지막으로 drop(Box::from_raw)는 포인터를 받아 다시 Box 타입으로 되돌린 뒤 파괴하는 방법입니다.
이제 C++ 헤더 robot_joint.hpp를 만듭니다.
#pragma once
#include <memory>
namespace robot_joint::rust {
/// Rust 객체에 대한 포인터로 사용할 불투명 타입의 전방 선언.
struct Joint;
} // namespace robot_joint::rust
extern "C" {
extern void robot_joint_free(robot_joint::rust::Joint*);
}
/// 함수 템플릿 인자로부터 커스텀 deleter 생성.
template<auto fn>
struct deleter_from_fn {
template<typename T>
constexpr void operator()(T* arg) const {
fn(arg);
}
};
namespace robot_joint {
/// Rust 쪽에 살아있는 robot_joint 객체에 대한 move-only 핸들.
class Joint {
public:
Joint() noexcept;
~Joint() noexcept = default;
Joint(Joint&& other) noexcept = default;
Joint& operator=(Joint&& other) noexcept = default;
private:
std::unique_ptr<rust::Joint, deleter_from_fn<robot_joint_free>> robot_joint_;
};
} // namespace robot_joint
C++는 템플릿 인자로 deleter를 지정하면 Rust 객체의 정리를 대신 처리해 주는 특별한 포인터 타입을 제공합니다. Rust 소멸자를 호출하려면 헤더에 extern 정의가 필요합니다. unique_ptr는 이동 전용이므로, 복사 생성과 복사 대입은 비활성화됩니다. 이 C++ 클래스는 이제 가능한 한 안전해졌습니다.
#include "robot_joint.hpp"
extern "C" {
extern robot_joint::rust::Joint* robot_joint_new();
}
namespace robot_joint {
Joint::Joint() noexcept
: joint_{robot_joint_new()} {
}
} // namespace robot_joint
여기서는 C++ 인터페이스의 소스 파일을 만듭니다. 다시 extern "C"를 사용하여, C++ 코드가 Rust에서 제공하는 C 함수를 호출해 joint 객체를 만들 수 있도록 합니다.
마지막으로, 가장 까다로운 부분은 이를 CMake 프로젝트와 호환되게 만드는 일입니다. 저는 그 주제에 대해 후속 블로그 글을 썼습니다.
앞서 수동 접근을 택한 이유가 C++ 쪽 인터페이스에서 Eigen 타입을 쓰고 싶었기 때문이라고 했습니다. 이를 달성하는 간단한 예시입니다. Joint 타입에 다음과 같은 Rust 함수가 있다고 가정합니다.
impl Joint {
pub fn calculate_transform(&self, variables: &[f64]) -> Isometry3<f64>;
}
C++에서는 다음과 같은 인터페이스를 만들고 싶습니다.
class Joint {
public:
Eigen::Isometry3d calculate_transform(const Eigen::VectorXd& variables);
};
먼저 이 함수에 대한 Rust FFI 인터페이스를 만들어야 합니다.
use std::ffi::{c_double, c_uint};
#[repr(C)]
struct Mat4d {
data: [c_double; 16],
}
#[no_mangle]
extern "C" fn robot_joint_calculate_transform(
joint: *const Joint,
variables: *const c_double,
size: c_uint,
) -> Mat4d {
unsafe {
let joint = joint.as_ref().expect("Invalid pointer to Joint");
let variables = std::slice::from_raw_parts(variables, size as usize);
let transform = joint.calculate_transform(variables);
Mat4d {
data: transform.to_matrix().as_slice().try_into().unwrap(),
}
}
}
파라미터에 필요한 C 타입은 Rust 표준 라이브러리의 ffi 모듈에서 제공합니다. Rust의 calculate_transform를 호출하기 전에, 먼저 전달받은 파라미터로부터 Rust 타입을 구성해야 합니다.
흥미롭게도, 문서화되지 않았지만 얇은 포인터(thin pointer)는 ffi에서 사용할 수 있다는 사실을 이용합니다. 크기가 정해진 슬라이스는 런타임에 크기를 저장하지 않는 얇은 포인터입니다. 메모리 표현을 C로 설정한 구조체에 담아 값으로 반환함으로써, 크기 고정 슬라이스를 반환할 수 있습니다.
이제 C 함수들을 호출하는 C++ 함수를 작성할 수 있습니다.
struct Mat4d {
double data[16];
};
extern "C" {
extern struct Mat4d robot_joint_calculate_transform(
const robot_joint::rust::Joint*, const double*, unsigned int);
}
namespace robot_joint {
Eigen::Isometry3d Joint::calculate_transform(const Eigen::VectorXd& variables)
{
const auto rust_isometry = robot_joint_calculate_transform(
joint_, variables.data(), variables.size());
Eigen::Isometry3d transform;
transform.matrix() = Eigen::Map<Eigen::Matrix4d>(rust_isometry.data);
return transform;
}
} // namespace robot_joint
robot_joint_calculate_transform가 반환하는 Rust의 Mat4d 타입은 16개의 double로 된 고정 크기 배열을 담고 있습니다. 이 배열을 이용해 4x4 Eigen 행렬로 타입 캐스팅하고, 이를 Isometry3d에 대입하여 반환하면 됩니다.
훌륭한 C++ 및 Rust 인터페이스를 만들어 주는 다리를 놓는 일은 생각보다 간단합니다. 아마 C++을 사랑하는 동료들을 설득해 Rust로 코드를 작성하도록 허락을 받는 일이, 인터롭 자체보다 더 어려울 것입니다.
테스트가 없는 코드는 고장난 코드로 보는 편이 낫습니다. 이렇게 unsafe가 포함된 C++과 Rust 코드를 신뢰하려면, 모든 코드 경로를 운동시키는 테스트를 작성하고, sanitizer와 함께 실행해야 합니다. 다음 글에서는 훌륭한 C++ 라이브러리인 Catch2를 사용하여 주소 및 정의되지 않은 동작 sanitizer와 함께 C++ 바인딩을 테스트하는 방법을 보여드리겠습니다. 또한 이를 어떻게 하는지 설명하는 CMake 통합에 관한 후속 글도 작성했습니다.

레드 클리프 브리지
주요 인터롭에는 cxx 크레이트에 주로 의존하고, C++ 인터페이스를 구축하거나 매크로를 확장해 Isometry3 같은 타입을 처리하는 방향도 탐색해 보고 싶습니다. 큰 장점은 수동으로 작성하는 unsafe 코드의 양을 줄일 수 있다는 점입니다.
unique_ptr를 원시 포인터 대신 사용하자는 친절한 피드백을 주신 Kris van Rens께 감사드립니다.