분석 대상: github.com/zed-industries/zed · Rust · ~200 crates
Debug impl
Newtype은 보통 타입 안전성 때문에 만든다. 그런데 Zed는 Debug 출력까지
도메인 언어로 직접 구현한다.
// derive(Debug)를 쓰면 그냥 ReplicaId(5) 로 나온다
#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Ord, PartialOrd)]
pub struct ReplicaId(u16);
impl fmt::Debug for ReplicaId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if *self == ReplicaId::LOCAL { write!(f, "<local>") }
else if *self == ReplicaId::REMOTE_SERVER { write!(f, "<remote>") }
else { write!(f, "{}", self.0) }
}
}
derive(Debug)를 쓰면 로그에 ReplicaId(5)가 찍힌다.
직접 구현하면 <local>, <remote>처럼 의미 있는 이름이 나온다.
CRDTs 디버깅 시 수백 개의 로그 중에 어떤 복제본인지 바로 보인다.
struct UserId(u64) 같은 newtype은
Debug도 직접 구현해 도메인 언어로 출력하자.
타입 안전성에서 끝내지 말고, 디버그 경험까지 책임져라.
clone_from 수동 구현 — 힙 재할당 제거
Clone의 기본 clone_from 구현은 *self = other.clone()이다.
이는 기존 self의 힙 버퍼를 버리고 새 버퍼를 만든다.
직접 구현하면 기존 버퍼를 재사용할 수 있다.
impl Clone for Global {
fn clone(&self) -> Self {
Self { values: SmallVec::from_slice(&self.values) }
}
fn clone_from(&mut self, source: &Self) {
// 용량이 충분하면 재할당 없이 복사
self.values.clone_from(&source.values);
}
}
= 할당 1회 + 해제 1회
= 할당 0회 (대부분의 경우)
Zed의 Global은 버전 벡터(version vector)로 CRDTs 연산마다 수십~수백 번 복제된다.
이 최적화가 실제로 의미 있다.
clone_from을 구현하라. Vec, SmallVec, String 모두
clone_from을 지원하므로 내부 필드에 위임하면 된다.
라이브러리 내부 타입에만 구현 가능해야 하는 trait을 설계할 때, 문서로만 제한하는 건 충분하지 않다. Sealed trait 패턴은 이 제약을 컴파일러가 강제하게 만든다.
// private mod이 핵심 — 외부 크레이트에서 이 경로를 볼 수 없다
mod seal {
pub trait Sealed {}
}
// 퍼블릭 trait이 Sealed를 수퍼 trait으로 요구
pub trait InputEvent: seal::Sealed + 'static {
// ...
}
// 내부에서만 impl 추가 가능
impl seal::Sealed for KeyDownEvent {}
impl seal::Sealed for MouseMoveEvent {}
외부 크레이트에서 InputEvent를 구현하려면 seal::Sealed를
먼저 구현해야 한다. 하지만 seal이 완전 private module이라
외부에서는 경로 자체를 참조할 수 없다. 컴파일 오류.
GPUI에서 InputEvent를 외부가 구현하면 이벤트 디스패치 로직이 깨진다.
이 패턴으로 API 계약을 타입 시스템에 인코딩했다.
mod seal { pub trait Sealed {} } 패턴을 써라.
주석이나 문서로는 부족하다 — 컴파일러가 강제해야 한다.
PhantomData<fn(T) -> T> — 불변 분산(invariance) 제어
PhantomData를 쓸 때 분산(variance)을 의식적으로 선택해야 한다.
Zed의 Entity<T> 핸들은 의도적으로 불변(invariant)으로 만들었다.
pub struct Entity<T> {
pub(crate) any_entity: AnyEntity,
pub(crate) entity_type: PhantomData<fn(T) -> T>, // invariant
}
| PhantomData 형태 | 분산 | 용도 |
|---|---|---|
PhantomData<T> | 공변(covariant) | T를 소유하거나 읽기만 하는 경우 |
PhantomData<fn(T) -> T> | 불변(invariant) | ID 핸들, 슬롯, 제네릭 포인터 |
PhantomData<fn(T)> | 반공변(contravariant) | 거의 안 씀 |
PhantomData<*mut T> | 불변(invariant) | raw pointer 래핑 |
Entity<T>가 공변이면 Entity<Dog>을
Entity<Animal>로 암묵적으로 캐스팅할 수 있다.
그러면 앱 컨텍스트에서 잘못된 타입으로 엔티티를 읽어올 수 있어 UB.
불변으로 만들어 이 캐스팅을 컴파일 타임에 차단한다.
PhantomData<T>가 기본값이지만, ID처럼 동작하는 핸들엔
PhantomData<fn(T) -> T>가 맞다.
Subscription
이벤트 구독을 등록하면 Subscription을 반환한다.
이 값이 drop되면 자동으로 구독이 해제된다.
명시적으로 영구 구독하려면 detach()를 호출한다.
#[must_use] // 실수로 버리면 경고
pub struct Subscription {
unsubscribe: Option<Box<dyn FnOnce() + 'static>>,
}
impl Drop for Subscription {
fn drop(&mut self) {
if let Some(f) = self.unsubscribe.take() { f(); }
}
}
impl Subscription {
/// 명시적으로 영구 구독 — drop해도 해제되지 않는다
pub fn detach(mut self) {
self.unsubscribe.take();
}
}
세 가지 사용 패턴:
// 1. 변수에 저장 — 변수가 drop될 때 구독 해제
let _subscription = cx.observe(&model, |this, cx| { ... });
// 2. 즉시 버리기 — 즉시 구독 해제 (#[must_use] 경고 발생)
// let _ = cx.observe(...); ← 컴파일러가 경고
// 3. 영구 구독
cx.observe(&model, |this, cx| { ... }).detach();
Option<Box<dyn FnOnce>>를 쓰는 이유: Drop과 detach가 모두
"내용을 take해서 처리 or 무시"하는 구조를 공유하기 때문이다.
Drop + #[must_use] + detach() 삼총사로 설계하라.
사용자가 의도를 명시적으로 코드에 표현하게 강제한다.
"캐시에 있으면 읽기, 없으면 계산 후 쓰기" 패턴에서
일반 RwLock은 TOCTOU 문제가 있다.
parking_lot의 upgradable_read로 해결한다.
fn read_metrics<T>(&self, font_id: FontId, read: impl FnOnce(&FontMetrics) -> T) -> T {
let lock = self.font_metrics.upgradable_read(); // 읽기 락 (다른 읽기 허용)
if let Some(metrics) = lock.get(&font_id) {
read(metrics) // 캐시 히트 — 쓰기 락 없이 반환
} else {
let mut lock = RwLockUpgradableReadGuard::upgrade(lock); // 원자적 쓰기 전환
let metrics = lock.entry(font_id).or_insert_with(|| {
self.platform_text_system.font_metrics(font_id)
});
read(metrics)
}
}
read() 락 획득drop(read guard)write() 락 획득upgradable_read() 획득upgrade() — 원자적 전환std::sync::RwLock에는 upgradable read가 없다.
Read-check-write 패턴이 필요하면 parking_lot의
RwLock을 써라.
FluentBuilder trait — 조건부 빌더 체이닝
빌더 체인 중간에 if를 쓰면 코드가 지저분해지고
타입 추론이 꼬일 수 있다. Zed는 when 메서드를 trait으로 만들어
모든 빌더 타입에 blanket impl한다.
pub trait FluentBuilder {
fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
where Self: Sized {
if condition { then(self) } else { self }
}
fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
where Self: Sized {
if let Some(v) = option { then(self, v) } else { self }
}
fn when_else(self, condition: bool, then: impl FnOnce(Self) -> Self,
otherwise: impl FnOnce(Self) -> Self) -> Self
where Self: Sized {
if condition { then(self) } else { otherwise(self) }
}
}
let mut d = div().flex();
if is_focused {
d = d.border_color(blue);
}
if let Some(msg) = error {
d = d.child(text(msg));
}
d
div()
.flex()
.when(is_focused, |d|
d.border_color(blue))
.when_some(error, |d, msg|
d.child(text(msg)))
FluentBuilder를 blanket impl하라.
로직의 흐름이 한 체인으로 읽힌다.
GPUI는 매 프레임마다 전체 UI 트리를 재구성한다.
각 요소에 Box::new()를 쓰면 수천 번의 힙 할당이 발생한다.
대신 프레임 단위 아레나를 쓰고 clear() 한 번으로 전체를 리셋한다.
pub struct Arena {
chunks: Vec<Chunk>,
valid: Rc<Cell<bool>>, // clear() 이후 사용 감지용
}
pub struct ArenaBox<T: ?Sized> {
ptr: *mut T,
valid: Rc<Cell<bool>>, // Arena와 공유
}
impl Arena {
pub fn clear(&mut self) {
self.valid.set(false); // 기존 핸들 무효화
self.valid = Rc::new(Cell::new(true)); // 새 세대 시작
// chunks는 메모리 해제 없이 포인터만 리셋
for chunk in &mut self.chunks { chunk.len = 0; }
}
}
impl<T: ?Sized> Deref for ArenaBox<T> {
fn deref(&self) -> &Self::Target {
assert!(self.valid.get(), "ArenaRef used after Arena::clear");
unsafe { &*self.ptr } // unsafe이지만 위에서 검사
}
}
프레임 단위로 clear()를 호출하면 기존 ArenaBox들의
valid 플래그가 false로 설정된다.
오래된 핸들을 실수로 역참조하면 패닉이 발생한다.
실제 제품 빌드에서는 assert를 제거해 오버헤드를 없앨 수 있다.
unsafe를 쓸 때는 그 위에 런타임 검사 계층을 얹어라.
Rc<Cell<bool>>처럼 가벼운 도구로 use-after-free 류의
오류를 개발 중에 빠르게 잡을 수 있다.
키가 이미 유일한 정수(TypeId, UUID 등)라면
해시 연산 자체를 없애버릴 수 있다.
pub struct TypeIdHasher { value: u64 }
impl Hasher for TypeIdHasher {
fn write(&mut self, bytes: &[u8]) {
// TypeId는 내부적으로 u64 하나 — 그 8바이트를 그대로 쓴다
self.value = u64::from_ne_bytes(bytes[..8].try_into().unwrap());
}
fn finish(&self) -> u64 { self.value }
}
impl BuildHasher for TypeIdHasherBuilder {
type Hasher = TypeIdHasher;
fn build_hasher(&self) -> TypeIdHasher { TypeIdHasher { value: 0 } }
}
// 사용
type TypeMap = HashMap<TypeId, Box<dyn Any>, TypeIdHasherBuilder>;
TypeId는 컴파일러가 보장하는 유일한 64비트 정수다.
충돌이 원리적으로 불가능하므로 해시 연산을 완전히 생략했다.
Zed 내부의 전역 타입 레지스트리가 이 해시맵을 극도로 자주 조회한다.
FxHasher보다 빠르다.
단, 일반 데이터에 쓰면 해시 충돌로 O(n) 성능 붕괴 — 도메인을 알 때만 써라.
Zed의 텍스트 버퍼, 로프(Rope), 진단 목록이 모두 동일한 SumTree<T> 구현을 쓴다.
"바이트 오프셋으로 탐색"과 "줄 번호로 탐색"을 같은 트리 코드가 지원하는 비결은
Item, Summary, Dimension 세 trait의 분리다.
pub trait Item: Clone {
type Summary: Summary;
fn summary(&self, cx: <Self::Summary as Summary>::Context<'_>) -> Self::Summary;
}
pub trait Summary: Clone {
type Context<'a>: Copy; // GAT — 탐색 컨텍스트를 zero-copy로 전달
fn zero<'a>(cx: Self::Context<'a>) -> Self;
fn add_summary<'a>(&mut self, summary: &Self, cx: Self::Context<'a>);
}
// "어떤 방향으로 탐색하는가"를 별도 trait으로 분리
pub trait Dimension<'a, S: Summary>: Clone + Default {
fn add_summary(&mut self, summary: &'a S, cx: S::Context<'_>);
}
// 실제 사용: 같은 트리를 두 가지 방향으로 탐색
let offset: usize = tree.summary().bytes; // 바이트 오프셋
let row: u32 = tree.summary().lines; // 줄 번호
// Dimension으로 특정 지점까지 커서 이동
let mut cursor = tree.cursor::<ByteOffset>();
cursor.seek(&ByteOffset(1024), Bias::Left, &()); // 1024바이트 지점으로
type Context<'a>)가 필요한 이유:#[must_use] — 실수로 버리는 핸들 경고clone_from — 버퍼 재사용