-
러스트 찍먹 후 느낀점?프로그래밍/Rust 2021. 6. 13. 00:36
면책사항) 튜토리얼을 끝낸 후 첫인상일 뿐이므로, 정확하지 않을수도 있다.
귀여운 >.< Ferris
좋았던 점.
1. 툴체인!!
누구도 부정할 수 없다.
Cargo, rustup, Rustfmt, Clippy, Rust Analyzer등의 공식툴!!
빌드 툴부터 생각해보자.
네이티브 계열에서 생각나는건 Make!!
어느정도 규모가 있는 프로젝트는 크로스 플랫폼 대응이나 각종 설정을 적용하기 위해 Meta Make 시스템을 사용하는게 일반적이다.
대표적인게 GNU Autotools(Autoconf, Automake, Libtools). [Introduction to GNU Autotools, The Basics of Autotools]
M4와 Make 두개의 작은 언어를 더 알아야만 한다..ㅋㅋㅋ
나중에 CMake가 나왔지만 역시 그리 쉽지만은 않다ㅠㅠ
이외에도 GN, Meson과 같은 빌드 툴이 나와있다.
이건 단순히 빌드툴에 한정지었을 때 뿐만이다.
라이브러리 버전이 안맞거나 헤더가 없다고 빌드가 안된적이 많지 않던가?
이래서 필요한게 vcpkg나 conan같은 패키지 매니저.
이외에도 모노레포나 조건부 컴파일/ 선택적 종속성은 물론 테스트, 벤치마크, 문서화 등이 필요한데, Cargo는 모두 지원한다.
게다가 그냥 지원하는게 아니라 소스코드 파일 안에서 테스트, 문서화 등을 모조리 지원해버린다.
그야 말로 만능 툴이다.
러스트 자체의 버전을 관리하는 rustup, 포매팅의 Rustfmt, 린트의 clippy, IDE 지원용인 Analyzer 모두 공식적으로 지원하기 때문에 코드 자체에만 집중할 수 있는 환경을 만들어준다.
기존의 개발환경이 얼마나 별로였는지 역체감이 너무 심하다ㅠㅠㅠ
엉망인 파이썬, 난잡한 자바스크립트의 툴들과 비교해도 확실한 우위라 할 수 있다.
2. 메모리 안전
메모리 관련 오류는 성능에 앞서 보안에 치명적이다. [Microsoft: 70 percent of all security bugs are memory safety issues, Chrome: 70% of all security bugs are memory safety issues]
다행히 GC(Garbage Collection) 언어는 일반적으로 메모리 안전하다.
그러나 GC를 위한 런타임이 필요하거나 Stop the world가 발생하는 등 성능 오버헤드가 존재하기 때문에 성능을 중시하면 메모리를 직접 할당/해제하는 언어들을 사용할 수 밖에 없다.
물론 Valgrind로 메모리 누수를 잡거나, Go의 defer처럼 실수를 줄여주기 위한 문법이 있을수 있다.
Swift처럼 ARC(Automatic Reference Counting)로 메모리를 관리하기도 하지만 메모리 안전을 보장하지 않는다.
게다가 ARC의 경우 할당/해제 시 참조 카운터 또한 증감하기 때문에 오버헤드가 존재한다.
C/C++처럼 Zero Cost를 지향하는 경우는 사용하기 힘들다는 말이다.
D언어가 메모리 안전한 스펙을 어느정도 정해 놓았기 때문에 어느정도 메모리 안전함을 보장한다. [DIP1000 Scoped Pointers]
그러나 D언어는 기본적으로 GC를 사용하는 언어라 Zero overhead를 달성했다기에 애매한 점이 있는데다 No GC일때 러스트 수준으로 잡아주지는 않는다.
러스트는 소유권과 차용 시스템으로 Zero overhead 메모리 안전, 데이터 경합이 없는 동시성을 달성한다. [Fearless Concurrency with Rust]
Encore나 Pony가 러스트와 비슷하게 메모리 안전을 보장한다. [A Comparison of the Capability Systems of Encore, Pony and Rust]
권한을 사용하는 Pony의 아이디어가 꽤 재미있다. [Reference Capabilites in Pony for everybody, Reference Capabilities, Consume and Recover in Pony]
마치 리눅스 chmod 파일 권한을 보는 느낌
RC의 경우, 요즘 재밌는 기술이 몇가지 나오는 것 같다.
Vale의 레퍼런스 카운팅을 대체하기 위한 세대 참조가 특이하다. 러스트에는 genref 라이브러리가 있다.
Koka도 레퍼런스 카운팅의 성능을 향상하기 위해 Perceus라고 적극적인 재사용을 한다.
3. 성능
C++은 잘쓰면 빠르다.
그러나 함정이 많다.
그 중 많은 것은 Class 기반의 OOP 때문이며, 각 명령어가 5~8개의 어셈블리로 변환되던 C와 다르게 생성/소멸자가 있어 예상이 어렵고 클래스의 가상함수 등은 인라인을 어렵게 만들기도 한다.
때문에 온갖 복잡한 규칙들이 생겨났다.
- 생성자의 초기화 때는 멤버 초기화 리스트를 사용해야 함 [PVS Studio]
- 복사 생성자를 정의하여 RVO, NRVO가 가능하도록 해야 함 [Copy elision]
- EBO를 사용해 빈 클래스의 크기를 1바이트만 사용하게 만들 수 있음
- CRTP를 사용해 정적 다형성을 달성할 수 있다.
- 가상 메소드의 경우, 컴파일 타임에 정보를 알게 하면 인라인이 가능하며, final 키워드를 사용할 수 있다면 쓰는게 좋다. [Can Virtual Functions be Inlined in C++?, The Performance Benefits of Final Classes]
러스트는 보다 간단하게 최적화가 가능하다.
- RVO를 지원하는걸로 알려져 있으며(Returning pointers), EBO도 달성하기가 보다 수월하다 (Zero sized types).
- 상속, virtual이 없는 러스트의 특성상 성능을 예측하기 쉽다. [Abstraction without overhead: traits in Rust]
- Try-Catch 또한 익셉션이 발생할때 느려지기로 유명한데 Result 타입을 사용하면 문제가 없을 뿐만 아니라 같은 컨텍스트에서 처리할 수 있다는 장점이 있다. [C++ 이야기 서른두번째: 예외가 성능에 미치는 영향, CppCon 2017: Dave Watson “C++ Exceptions and Stack Unwinding”]
- 극도로 최적화된 반복자를 사용하여 일반 반복문보다 빠르기도 하다.
- constexpr 대신 const fn, const generics를 사용
- rvalue, std::move는 알 필요가 없다!! [C++ Move Semantics Considered Harmful (Rust is better)]
라이브러리 면에서도 흥미로운데, C++의 STL은 논쟁의 대상이었다.
특히 게임관련 계통은 디버깅 모드의 성능, 메모리 할당 관련 때문에 바퀴의 재발명을 자주하기로 유명했다.
러스트 컬렉션의 경우, pcwalton의 댓글에 따르면 EASTL(문서)을 참고하여 allocator_api를 만들었다고 알려져 있다.
정규식 엔진이 훌륭하여 최악의 경우 선형 시간이며, PCRE2 JIT과 비교해도 느리지 않다. [A comparison of regex engines]
신세대 언어답게 많은 부분이 신중하게 결정되었다.
그럼 보다 전통적인 C와 비교하면 어떨까?
다음 문서에서 잘 설명해주고 있다.
앞서 설명한 데이터 경합 없는 동시성으로 인해 과감한 쓰레드 사용도 가능하여 고성능을 만들기 좋다.
최근에 또 떠오르는 Zig와 비교한다면
4. 간결함
모든 블럭은 일종의 표현문이라 값을 반환할 수 있고, 블럭의 마지막에 세미콜론을 넣지 않으면 return으로 처리할 수 있다.
처음에 return을 쓰지 않는 방식에 거부감을 느꼈지만 계속 보다보니 뇌이징이 되서 좋아보인다..
이러한 문법은 처음에 Rust를 구현할 때 썼던 OCaml에서 자주보이던 구문들.
let big_n = if n < 10 && n > -10 { println!(", and is a small number, increase ten-fold"); 10 * n } else { println!(", and is a big number, reduce by two"); n / 2 };
구조체와 Enum의 유닛, 튜플, 레코드 값 방식은 단순, 독특, 유용하면서도 강력하다.
역시 OCaml 만세enum Message { Quit, // 유닛 Write(String), // 튜플 Move { x: i32, y: i32 }, // 레코드 } struct QuitMessage; // 유닛 구조체 struct WriteMessage(String); // 튜플 구조체 struct MoveMessage { // 레코드 구조체 x: i32, y: i32, }
흔히 객체지향에서 말하는 정적, 일반 메소드 기능은 &self 파라미터가 들어가느냐로 쉽게 구분해놨다.
impl MoveMessage { // 연관함수 fn linear(size: i32) -> MoveMessage { MoveMessage { x: size, y: size } } // 메소드 fn area(&self) -> i32 { self.x * self.y } }
패턴매칭은 심플하면서도 스위치문 따위와는 비교도 안되게 강력하다.
let x = 1; match x { 1 => println!("one"), 2 | 3 => println!("two or three"), 4 ... 6 => println!("four to six"), _ => println!("anything"), }
구조체나 Enum 까지도 모두 커버가 가능하다.
let msg = Message::Move(0, 50); match msg { Message::Quit => { println!("The Quit variant has no data to destructure.") }, Message::Write(text) => println!("Text message: {}", text), Message::Move { x: 0, y } => { println!( "Move in the y direction {}", y ); } Message::Move { x, y } => { println!( "Move in the x direction {} and in the y direction {}", x, y ); } }
if-let과 while-let은 강력한 패턴매칭을 조건문에서도 쓸수 있게 만들었다.
이후에 나올 let-else, let-chains도 기대해도 좋겠다.
let number = Some(7); let letter: Option<i32> = None; let emoticon: Option<i32> = None; let i_like_letters = false; if let Some(i) = number { println!("Matched {:?}!", i); } if let Some(i) = letter { println!("Matched {:?}!", i); } else { println!("Didn't match a number. Let's go with a letter!"); }; if let Some(i) = emoticon { println!("Matched {:?}!", i); } else if i_like_letters { println!("Didn't match a number. Let's go with a letter!"); } else { println!("I don't like letters. Let's go with an emoticon :)!"); }
파이썬에서 쓰던것과 유사한 범위 구문도 가능하며, 슬라이스 개념은 재밌다.
let arr = [0, 1, 2, 3, 4]; assert_eq!(arr[ .. ], [0, 1, 2, 3, 4]); assert_eq!(arr[ .. 3], [0, 1, 2 ]); assert_eq!(arr[ ..=3], [0, 1, 2, 3 ]); assert_eq!(arr[1.. ], [ 1, 2, 3, 4]); assert_eq!(arr[1.. 3], [ 1, 2 ]); // This is a `Range` assert_eq!(arr[1..=3], [ 1, 2, 3 ]);
러스트 1.58에서는 fstring도 들어갔다고 한다!!
let x = "world"; println!("Hello {x}!") let items = vec![10, 20, 30]; println!("{items:?}")
C와 비교해서 편한 기능은 Deref라 불리는 자동 역참조 기능이다.
구조체에서 메서드도 (*object).method()를 object->method()라고 썼던 것과 달리 Deref가 자동으로 참조해준다.
fn hello(name: &str) { println!("Hello, {}!", name); } fn main() { let m = MyBox::new(String::from("Rust")); // Deref가 없을 때 hello(&(*m)[..]); // Deref가 있을 때 hello(&m); }
5. 매크로
매크로 한정 리스프가 더 쉬워보이지만, 전위식이라는 점과 괄호가 무조건 있어야 하는 언어 특성이 크게 작동한다고 생각한다.
러스트의 매크로는 일반 문법과 똑같고(Syntactic) 확장 시 실수로 식별자가 캡처되지 않도록 보장한다(Hygenic).
문제는 만드는게 너무 어렵다...ㅠㅠㅠ [Rust 로 복잡한 매크로를 작성하기: 역폴란드 표기법]
Hygienic macro에 대한 글들
6. 기타
chalk라는 라이브러리가 특이했는데, 러스트의 trait 시스템에 프롤로그의 논리 솔버를 도입한 것이다.
rust analyzer가 사용하는 걸로 알고 있다.
아쉬웠던 점.
아쉬운점의 대부분은 함수형 / 타입관련 지원이다.
1. 함수형
꼬리재귀
관련 RFC: Proper tail calls #1888
러스트는 꼬리재귀를 보장하지 않는다.
함수형을 지원하려면 나름(?) 필수 기능 아닐까 싶다
느긋함(Lazy)
관련 RFC:
하스켈과 같은 함수형의 특징 중 하나는 Lazy하다는 것이다.
Lazy 함은 일반적으로 느리지만 다루어야할 리소스 양이 많거나 무한을 다루어야 할 때 효과적이다.
std::sync::Lazy처럼 RFC가 제안되어 있다.
(비슷한 느낌으로 Immutable 자료구조 또한 표준으로 있었으면.. ㅎㅎ)
HVM같은 경우, Lazy clone 프리미티브를 이용해 공짜에 가깝게 복제하고, 람다 함수의 계산을 공유하여 성능 면에서 이점이 존재한다.
커링(Currying)
관련 RFC: Currying RFC #191
커링의 경우 cutlass라는 매크로 라이브러리가 존재한다.
2. 타입
고차타입(Higher Kinded Types)
관련 RFC: Higher kinded polymorphism #324
고차타입은 컨테이너 유형등을 다룰때 유용하다.
다음 글들을 보면 컨셉에 대해 이해할 수 있을 것이다.
- Rust/Haskell: Higher-Kinded Types (HKT)
- Higher-kinded types: the difference between giving up, and moving forward
- Comparing Traits and Typeclasses
- Value / Type / Kind 와 Higher Kinded Type (Feat. 고차함수)
선형타입(Linear type)
관련 RFC: Add linear type facility #814
러스트의 차용 시스템은 훌륭하지만 완벽하지는 않다. [The Pain Of Real Linear Types in Rust]
일반적으로 사용할 수 있는 횟수에 따라 4가지로 나뉘는데,
- 여러번 사용해도 됨: 기본
- 한번 이상 쓸 수 없음: Affine 타입
- 한번은 사용해야 함: Relevant 타입
- 정확히 한번만 사용해야 함: Linear 타입
러스트는 사용하면 moving이 일어나기 때문에 기본적으로 Affine 타입이며, 사용하지 않았을 때 컴파일러가 경고해주는게 Relevant 타입이라 할 수 있다.
Linear(선형) 타입은 한번만 실행하기 때문에 file 사용 후 close가 적절히 실행되지 않는 것을 보완할 수 있다.
또한 최대 한번만 값을 사용하도록 강제하기 때문에 free가 여러번 일어나 메모리 버그가 일어나는 일을 방지하고, 인라인 관련 최적화를 수행할 수도 있다.[Linear types make performance more predictable]
Idris 2의 Quantitative Type Theory (QTT)는 컴파일 타임에만 존재하는 변수에 대한 타입을 제공하기도 한다.
가변 제네릭
관련 RFC: variadic generics #10124
type Tuple<..T> = (..T); // the parens here are the same as the ones around a tuple. // use => expansion Tuple<> => () Tuple<int> => (int,) Tuple<A, B, C> => (A, B, C)
익명 Sum 타입
관련 RFC: Anonymous sum types #294
타입스크립트를 쓰던 사람들은 유니온(Union) 타입으로 익히 알고 있을 타입이다.
얼마나 편한지는 다들 공감할 것 같다.
종속(Dependent) 타입과 정제(Refinement) 타입
관련 RFC:
숫자에서 0~100까지만 사용할 수 있다면 어떨까?
타입을 제약할 수 있다면 보다 안전한 프로그램을 만들 수 있을 것이다.
자동완성도 좋아진다!!
종속 타입(Dependent type)은 일반적으로 런타임에 검증하며, 정제 타입은 컴파일 타임에 검증한다.
타입스크립트의 literal type도 이 범위라 생각할 수 있을 것이다.
Enum 강화
관련 RFC:
타입스크립트를 한 사람은 알겠지만, Enum은 type으로 표현할 수도 있다.
GADT도 있었으면..
3. 컴파일
컴파일 타임 혹은 빌드타임에 할 수 있는 것들이 몇가지 있으면 한다.
제네레이터와 코루틴
관련 RFC: Experimentally add coroutines to Rust #2033
코틀린의 코루틴은 정말 잘 만들어져 있다.
Rust의 async 설계가 잘되어 있다고는 하나 명시적인 await를 해줘야하는 지연된 값으로 좀 다른 것 같다.
Stackless 코루틴의 경우 컴파일러가 가능한 영역.
계약에 의한 설계와 증명관련 기능
관련 RFC:
일부는 어쩌면 종속과 정제타입의 연장선상일 수도 있지만..
D언어의 계약에 의한 프로그래밍(Design By Contract)은 멋진 기능이다.
in source test와 함께 쓰면 편할 듯.
ATS(Applied Type System) 언어의 예처럼 증명을 구현과 통합하게 만들 수 있다.
TDD의 단점은 엣지 케이스를 빠트릴 수 있기 때문이다.
물론 일반적으로 증명을 사용해야 할 경우는 많지 않겠지만, 코어단에서는 유용할 수있다.
증명용 언어에서는 Agda나 F*가 눈에 띄는 것 같다.
컴파일 타임 평가
Zig의 comptime은 꽤 멋진기능이며, 러스트의 const는 아직 C++의 constexpr나 D의 static if등은 간단하게 컴파일 타임에 평가를 진행한다.
러스트에도 라이브러리가 있는 것 같긴 하다.
아마 리플렉션이 필요할지도.
4. 편의성을 위한 다양한 기능들
측정 단위
F#의 단위에 대한 구문은 깔끔하다. [ F# Basic Units of Measure]
개인적으로 GUI를 다룰려면 이 기능이 꼭 있어야 한다고 생각
let dpiValue = 150.0<dpi> let inchValue = 8.0<inch> let pxValue = 1200.0<px>
다이나믹 스코프
다이나믹 스코프는 Dependency injection을 매우 쉽게 만들어준다.
스킴의 구현이 좋은편이며, rust에도 fluid-let이라는 라이브러리가 있는 것 같다.
Effect 다루기
대수적 효과나 잘 설계된 이펙트 핸들러를 보고 싶다.
Koka, 스칼라의 ZIO Direct, Ocaml의 EIO가 잘 만들어진 예시들.
5. 몇가지 구문설탕
구문 설탕이므로 있었으면 편하지 않을까~ 정도로만 봐주면 좋겠다.
기본 자료구조 매크로
관련 RFC: Add hashmap, hashset, treemap, and treeset macros #542
경험상 풍부한 기본 자료구조는 생산성에 커다란 도움이 된다.
velcro정도의 문법이 적당해 보인다.
컴프리핸션(comprehension)은 보통 파이썬 또는 하스켈 버전 중 하나일텐데 보통은 파이썬 버전을 좋아하지 않을까.
삼항연산자
관련 RFC: Rust Needs The Ternary Conditional Operator (-?-:-) #1362
일반적인 알골 기반 언어를 자주 쓴 사람이라면 그리워할 그 것.
이상한 삼항연산자가 있던 파이썬 쓸 때 너무 아쉬웠었다.
구조체 기본 필드
관련 RFC:
struct에 기본 값이 있다면 어떨까?
위의 lazy 제안과 맞물려 좋은 시너지가 될 것이다.
트레잇 한번에 구현
관련 이슈:
subtrait을 사용했을때는 한번에 구현하는게 멘탈모델에 더 적합한 것 같다.
함수의 키워드, 옵션(과 기본), 나머지 매개변수
관련 RFC: functions with keyword args, optional args, and/or variable-arity argument (varargs) lists #323
키워드 인수는 모르겠으나, 옵션 값, 나머지 매개변수는 꼭 좀 쓰고 싶은 기능이다..
함수 파이프라인
F#의 파이프라인 문법도 좋지만, 타입의 힘을 빌려 D의 연쇄 함수 호출 문법(Uniform Function Call Syntax (UFCS))이나 하스켈의 Type Directed Name Resolution(TDNR)처럼 점(.)으로 접근해버리면 어떨까?
데코레이터
파이썬, 타입스크립트의 데코레이터, 자바의 어노테이션는 Rust의 Derive를 만드는 것보다 훨씬 쉽다. (메서드를 호출한 결과일 뿐)
rust-adorn이 유명하다.
이와 비슷하지만 다른걸로는 코틀린의 DSL도 Kotlin으로 DSL 만들기: 반복적이고 지루한 REST Docs 벗어나기를 읽어봤다면 강력함을 알 수 있다.
하지만 러스트 매크로로 만드는 것과 난이도차이가 크지 않기에 필요없을 듯.
6. REPL과 핫 리로드
러스트의 풍부한 툴 인프라 중 유일한 불만은 REPL과 핫 리로드 기능이다.
스크립트 언어 계열을 써봤다면 REPL을, 웹 개발이나 Flutter관련 개발을 해봤다면 핫 리로드가 익숙할 텐데 저 둘의 생산성 증가는 상당하다.
REPL의 경우 프로토타입이나 간단한 함수나 로직을 테스트하기 좋고, 핫 리로드는 실제 어플리케이션의 상태가 유지되며 실시간으로 반영되기 때문에 불필요한 컴파일 시간과 테스트에 걸리는 시간을 줄일 수 있다.
다행히 공식 툴은 아니지만 구글에서 evcxr라는 REPL을 개발 중이라 한다.
그러나 핫 리로드는 관련글들이 있지만 아직...
C++의 경우 마이크로소프트는 비주얼 스튜디오 2022에서!! 선보였다 [Write and debug running code with Hot Reload in Visual Studio (C#, Visual Basic, C++), Hot Reload for C++]
마소가 요즘 밀어주려고 하던데 비주얼 스튜디오에서 지원해주면서 어떻게 안될까..?
분류 짤
몇가지 재밌는 분류짤을 올리며 마치도록 하겠다.
Models of Generics and Metaprogramming: Go, Rust, Swift, D and More
댓글