Rust 프로그래밍 언어를 알게 된 지는 좀 오래되었지만 정작 코드를 짤 기회는 없었습니다. 어쩌다가 기회가 생겨서 나름 Rust로 비공개 라이브러리 하나를 만들어보게 되면서 여러 팁들이 생겨서 정리해볼까 합니다.
Rust에는 null 도 없고 Exception 도 없습니다. 이 모든걸 Result와 Option으로 처리하므로 Result와 Option에 익숙해져 두는 것이 도움이 됩니다.
Result와 Option이 enum이고 ADT임은 알고있다고 가정합니다. 모르신다구요? 이 글을 봐주세요.
Result는 타 언어의 Exception에 해당합니다.Ok(TValue): 성공했어요!Err(TError): 에궁... 실패했네요.Option은 null에 해당합니다.Some(TValue): null이 아니고 값이 있어요None: null이에요.?) 의 의미가 다르다다른 언어에서 물음표 연산자는 보통 null이 아닐때만 값에 접근하는 용도로 사용하죠. null 전파(null propagation)라고 부르죠?
Rust는 아닙니다. 보통 throw랑 같은 의미에요. ? 기호를 넣는 순간, Result::Err 와 함께 함수 밖으로 탈출하기 때문입니다.
null 전파와 같은 효과를 보려면 Option::map(함수)을 사용하거나 if let Some(val) = opt 같은 패턴 매칭을 적극적으로 활용합시다.
Result가 반환형인 함수에서 물음표 기호를 사용하면 바로 함수에서 탈출할수도 있습니다. 루프를 돌고있거나 할 때에는 원치 않는 동작이죠.
fn my_func() -> std::result::Result<String, std::io::Error> {
let content: Result<String, std::io::Error> = std::fs::read_to_string("./my_file");
let content: String = content?;
Ok(content)
}
기본값을 주기 위해 map_or를 썼습니다.
fn my_func() -> String {
let content: Result<String, std::io::Error> = std::fs::read_to_string("./my_file");
let content: String = content.map_or("default".to_string(), |s: String| s.trim().to_string());
content
}
상황에 따라 여러 방법으로 매칭할 수 있습니다.
fn my_func() {
// 그냥 조건에 넣고싶기만 한 경우
if let Ok(content) = std::fs::read_to_string("./my_file") {
content
} else {
return;
};
// 위의 if 구문은 "expression" 입니다. 바로 값으로 할당할 수 있어요.
let content: String = if let Ok(content) = std::fs::read_to_string("./my_file") {
content
} else {
"".to_string()
};
// rust 1.60 쯤에 추가되었던가요?
// 더 단순하게 쓸 수도 있게 되었습니다. 지저스 크라이스트 땡큐베리머치
let Ok(content) = std::fs::read_to_string("./my_file") else {
return; // continue를 쓴다던가
};
// 물론 match도 되죠.
let content = match std::fs::read_to_string("./my_file") {
Ok(content) => content,
Err(_err) => "".to_string(),
};
}
Err는 enum의 케이스(variant), Error는 에러 자체Err vs Error 헷갈리지 맙시다.
Err는 Result enum의 variant(케이스 하나)이고 (Result::Err),Error 는 에러 클래스 그 자체입니다. Result::Err(TError)의 TError가 Error 에요.Error가 있을수도 있다라이브러리의 경우, 자체적으로 Error를 정의하기도 합니다.
Error는 trait (인터페이스) 입니다.struct나 enum)를 만들며, 그 이름도 Error인 경우도 있습니다.std::io::Error 도 Error라는 이름을 똑같이 쓰고 있습니다. 얘는 인터페이스(trait)가 아니라 에러 클래스(struct) 입니다.std::fmt::Error 도 있네요! 얘도 struct이며, std::io::Error와는 다릅니다. 두 종류의 에러인 셈이죠.anyhow를 쓰자Error는 타 언어로 치면 인터페이스인 trait 입니다.
Garbage Collector가 있는 Java나 JavaScript, Python 같은 언어에서는 상속 관계에 있는 다양한 타입을 아무거나 막 반환해도 전혀 문제가 되지 않습니다.
하지만 C++이라면 객체가 잘려서 반환된다던가 하는 불상사가 벌어지기도 합니다. Rust도 GC가 있는 언어가 아니므로 아무렇게나 대충 반환할 수는 없습니다.
C++의 사례를 볼까요. C++에서는 자식을 값으로 반환하면 자식 부분은 썩둑 잘려요. (C++을 자주 안 쓰다가 오랜만에 쓰게 되면 자주 하는 실수입니다.)
// C++
class Parent {
public:
int parent_value = 0;
}
class Child {
public:
int child_value = 0;
}
Parent my_fn() {
Child child;
return child; // 자식 부분의 메모리가 뎅강 하고 잘린 채로 나가게 됩니다!
}
이런 문제를 해결하려면 포인터로 반환을 하던가, 스마트 포인터를 쓰거나 하잖아요?
그래서, 에러를 이렇게 반환하려고 해도 동작하지 않습니다.
fn my_func() -> Result<(), std::io::Error> {
// io::Error를 반환
let _fs_result = std::fs::read_to_string("my_file")?;
// fmt::Error를 반환 (문서에서 가져왔어요)
// 근데 fmt::Error를 반환하지는 않잖아요?
let mut output = String::new();
let _fmt_result = std::fmt::write(&mut output, format_args!("Hello {}!", "world"))?;
Ok(())
}
Rust에서는 dyn Trait을 쓰면 됩니다. Trait이 인터페이스니까, 어차피 동적로 함수 위치를 찾아서 실행해줘야 하잖아요? (동적 디스패치)
원래 dyn 을 적는 건 필수가 아니었던 거 같은데 아무튼 적어주면 좋겠죠
fn my_func() -> Result<(), dyn Error> {
// io::Error를 반환
let _fs_result = std::fs::read_to_string("my_file")?;
// fmt::Error를 반환 (문서에서 가져왔어요)
// 근데 fmt::Error를 반환하지는 않잖아요?
let mut output = String::new();
let _fmt_result = std::fmt::write(&mut output, format_args!("Hello {}!", "world"))?;
Ok(())
}
근데 dyn Error 는 속도를 내서 대충대충 막 짤 때 엄청 자주 쓰는 패턴이거든요. 그래서 여기에 이런저런 기능을 붙인 anyhow라는 라이브러리를 거의 필수적으로 사용합니다.
내 프로그램이 exe 실행파일이다! (bin 프로젝트) 일 때에도 유용합니다. 그 때는 정말로 에러를 출력해주고 터지면 그만이니까요.
fn my_func() -> anyhow::Result<()> {
// 여기에서는 io::Error를 반환하고
let _fs_result = std::fs::read_to_string("my_file")?;
// 여기에서는 fmt::Error를 반환하는데 이게 어떻게 가능하냐구요?
// 적절한 Into / From trait을 구현하면 알아서 변환해줍니다.
let mut output = String::new();
let _fmt_result = std::fmt::write(&mut output, format_args!("Hello {}!", "world"))?;
Ok(())
}
thiserror를 쓰자anyhow와 같이 세트로 많이 언급되는 게 thiserror입니다.
내가 라이브러리를 만들고 있고, 그래서 unwrap()같은 걸 도배해서 앱을 터트리는 대신 제대로된 에러 타입을 반환하고 싶다... 그래서 나만의 에러 타입을 만들어야 한다 - 이런 상황일 때 공수를 많이 줄여주는 라이브러리입니다.
thiserror의 깃헙에 가서 살펴봅시다. 아니면 docs.rs 도 괜찮구요.
라이브러리를 만들고 있다면 unwrap이나 expect는 쥐약입니다.
또한, 라이브러리에서는 뭉뚱그려진 에러인 dyn Error나 anyhow::Error 를 반환해서는 안 됩니다.
#[derive(Trait)] 어트리뷰트를 struct나 enum 위에 올리면 여러 함수가 자동으로 구현됩니다. 이렇게 쓸 수 있는 내장 trait 중 일부가 유용합니다. (커스텀으로 만드려면 proc macro ...... 으악 ㅎㅎ)
#[derive(Default)]Default::Default() 함수를 구현해줍니다. struct의 모든 멤버가 Default가 구현되어있다는 전제 하에서 말이죠. 기본적으로 이미 많은 타입이 구현되어 있으니, Default가 구현된 것들의 Default가 구현된 것들의......
아니 그게 아니고
struct 분해? 업데이트? 문법으로 손쉽게 struct를 초기화할 수 있게 됩니다. 아니 new 함수 넣는 게 정석이긴 한데 귀찮잖아요...
#[derive(Default)]
struct MyStruct {
pub a: i32,
pub b: i32,
pub c: i32,
pub d: i32,
}
let asdf = MyStruct {
a: 123,
b: 456,
..Default::default()
};
// 근데 default 자리에 객체를 넣을 수 있는 거도 아세요?
let gg = MyStruct {
c: asdf.c + 2,
..asdf
};
#[derive(Copy, Clone)].clone 호출이 되어야 복사하는 명시적 복사 지원이고,Into, From 둘 중 하나만 구현해주면 .into() 함수로 손쉽게 타입끼리 변환할 수 있습니다. 이건 수동으로 구현해줘야 합니다.
Rust가 C#이나 Kotlin 같은 언어에 있는, 아무 클래스에 내 함수를 구현하는 확장 함수(extension function) 기능을 자체적으로 지원하지는 않습니다. 그렇지만 Trait을 사용하면 거의 똑같은 효과를 낼 수 있습니다. 인터페이스를 반드시 정의하고, 그거에 대해서 확장함수를 구현해야한다는 뜻이죠.
pub trait MyExt {
fn length_doubled(&self) -> usize;
}
impl MyExt for String {
fn length_doubled(&self) -> usize { self.len() * 2 }
}
fn my_fn() {
println!("{}", "asdf".to_string().length_doubled());
}
A 라이브러리의 trait을 B 라이브러리의 struct에 구현하고 싶을 때가 있습니다. impl A_Trait for B_Struct 이건 안 됩니다. trait과 struct 둘 중 하나는 내 꺼여야 해요.
이걸 하고 싶다면 newtype 패턴을 사용하세요. struct My_B_Struct(B_Struct) 와 같이 새로운 타입으로 감싸고, impl A_Trait for My_B_Struct 를 하면 됩니다.
제네릭으로 넓게넓게 범위를 잡아서 Trait을 만들고, 기본 구현을 해놓으면 Blanket Implementation 이라고 해서 "어 이거 되겠네?" 싶으면 자동으로 구현해주는 경우가 있습니다. 분명 내가 구현하지는 않았는데 이런 함수 있었나...? 싶어도 당황하지 맙시다.
이걸 적극적으로 활용하는 라이브러리도 많습니다. 그냥 함수의 Trait인 FnOnce나 FnMut 같은거에다가 구현을 한다던가 하는 웹프레임워크도 있었던 거 같아요.
AsRef<str>함수에 impl 이 적혀있으면, 제네릭을 쓴 거랑 같은 효과입니다.
fn my_fn<T>(param: T) where T: AsRef<str> {}
랑
fn my_fn(param: impl AsRef<str>) {}
랑 아마 같은 걸거에요.
AsRef trait은 String과 &str 둘 다 입력으로 받을 때 유용한 trait으로, .as_ref() 를 함수 내에서 호출해서 String을 입력으로 받던 &str을 입력으로 받던 &str로 바꿔서 처리할 수 있습니다.
fn my_fn(param: impl AsRef<str>) {
let my_str: &str = param.as_ref();
println!("{}", my_str);
}
fn main() {
my_fn("Hello"); // &str
my_fn("World!".to_string()); // String
}
String 과 빌린 &str 구분하기String과 &str은 쌍으로 존재합니다.
String은 값을 직접 가지고 있는 객체이고,&str은 메모리 어딘가의 참조입니다.String::as_str() 로 참조를 뜯어올 수 있고, &str::to_string()으로 온전히 소유하는 사본 String을 만들 수 있습니다.이와 비슷한 패턴이 또 있습니다. PathBuf 와 &Path 입니다.
String, PatfBuf&str, &Path처음에는 경로 다룰 때 왜 이렇게 불편하게 두 개지... 했는데 알고보니 이게 당연한 거였더라구요. 이런 패턴을 직접 만드려면 알아보니 unsafe 코드를 써야 한다고 했던 거 같네요.
&str 를 struct에 넣으려면 라이프타임 적기보통은 struct에 문자열을 넣으려면 String을 쓰는 게 가장 속이 편합니다. 어딘가를 참조하는 게 아니라 내가 온전히 갖고 있으니까요.
하지만 nom 같은 라이브러리를 쓰다보면 struct에 버퍼의 참조를 넣고싶은 욕망이 생깁니다. 이럴 땐 <'a> 와 같이 라이프타임을 따로 기술해주면 됩니다.
fn my_func<'a>(buff: &'a [u8]) -> MyClass<'a> {
MyClass {
header_part: &buff[4..6]
}
}
fn my_func_2<'b>() -> MyClass<'b> {
// buff가 살아있는 동안은 asdf를 계속 쓸 수 있습니다.
let mut buff = [0 as u8;20];
buff[0] = 20;
let ref_buff = &buff;
// 반환이 안 되는 건 당연하죠. buff가 죽는걸...
// cannot return value referencing local variable `buff`
// returns a value referencing data owned by the current function
my_func(ref_buff)
}
Cow<'a, str> 를 쓴다면, clone()을 해도 소유권을 가져올 수 없는 함정이 있으니 주의합시다. 이럴때에는 ToBoundedStatic 같은 라이브러리를 씁시다.
'static 라이프타임은 그냥 쓰면 보통 생각하는 프로그램 내내 유지되는 static이 맞지만, 제네릭의 제약조건으로 들어가면 String 같은 값을 온전히 내가 가지고 있는 owned 타입도 포함합니다. 이게 무슨 개소리인가 하면...
Box와 Rc, Arc, Cell, RefCellBox는 힙에 있는 데이터입니다.Cell, RefCell 은 let mut 같은 수정가능여부 (mutability) 체크를 런타임으로 떠넘기기 위한 클래스입니다. 컴파일 타임에 체크를 못 하는 경우가 있거든요.Cell 은 값으로 꺼내고 넣을 때 사용합니다. let mut b = a; 랑 비슷합니다.RefCell 은 참조로 꺼내고 넣을 때 사용합니다. let mut b = &a; 랑 비슷합니다.Box랑 비슷한데 빌림(borrow) 체크를 한다고 보시면 됩니다.Rc, Arc 는 레퍼런스 카운팅입니다. C++의 std::shared_ptr 같은 녀석입니다.Arc는 atomic 으로, thread-safe 합니다.Rc<RefCell<T>>Arc<RefCell<T>Arc<RefCell<DBConnection>>이렇게 많이 합니다. 사용방법은 검색대체로 rust의 API에서 into 라는 단어가 들어간 함수들은 함수 자체가 소유권을 챙겨갈 때 많이 씁니다.
iter vs into_iteriter: 그냥 빌려서 보기만 합니다.iter_mut: 빌려서 수정까지into_iter: into가 있잖아요? 함수가 소유권을 가져갈 거에요.From / Into의 into도 해당인가...? 몰?루
공식 crates.io 사이트보다 서드파티인 lib.rs 사이트가 새로운 라이브러리를 발견하기에는 몇 배나 낫습니다. 라이브러리 검열을 시도한 개발자가 괘씸하긴 한데 어쩌겠어요 사이트가 너무 좋은걸...
firefox 브라우저의 arewefastyet.org 에서 시작한, 각 분야별 rust 라이브러리 정리 사이트가 있습니다. 우리 이거 아직 안 돼? 같은 의미죠.
더 있을 거 같은데 필요하시면 찾아보세요.
보통 https://docs.rs 에서 문서를 내가 빌드하지 않아도 확인해볼 수 있습니다. docs.rs 쪽에서 빌드하지 못하는 특수한 사례를 제외하면 보통 문서가 올라와 있습니다. 내가 문서를 직접 빌드하려면 cargo doc --open 이었던가요?
https://cheats.rs/ - 메모리 구조까지 알려줍니다. 헷갈릴 때 열어보면 완전 꿀입니다.
몇몇 라이브러리를 앞에서 소개했었는데 (anyhow, thiserror 등) 조금 더 적어둘까 합니다. 제 사견이 담겨있으니 참고로만 삼아주세요.
rayonserdetokiotokio 에코시스템의 axum 이 많이 추천되는 대상입니다. actix는 빠른데 액터 기반이라...tokio 에코시스템의 tracing (및 tracing-subscriber와 fmt feature 플래그까지 켜주면 완벽합니다)iced가 가장 유망해보이고, GPL도 괜찮다면 slint 쓰시면 됩니다.dioxusleptos, sycamore. leptos가 나중에 나왔는데 요샌 더 괜찮아보이네요. 둘 다 화이팅!Bevy에 관심이 많으신데, 에디터는 내년에나 나올 거 같아요... 여튼 이게 제일 핫합니다.일단 정리해둔 소재는 여기까지네요. 도움되셨으면 좋겠습니다.