요새 러스트가 핫하다고 해서 한번 배워보았고, 알고리즘 문제를 풀어보며 C++과 느낌이 어떻게 다른지 비교 해 보았다.
Why Rust?
요즘들어 파이썬이나 Node.js로 작업을 많이 하고 있었는데, 용량이 수십 MB, 수 GB 하는 데이터를 처리하다 보니 속도가 느린게 너무 심하게 체감이 되었다. 스크립팅 언어의 퍼포먼스 한계를 느끼면서, 네이티브로 컴파일되는 언어도 하나 배워야 할 필요성을 느끼게 되었다. 그래서 요즘 핫한 Go와 Rust 둘중에 고민하였는데,
결론부터 말하면 둘을 비교하는것 자체가 적절하지 않고, 어느 하나가 더 좋다고 할 수 없다고 느겼다. 이 둘이 목표로 하는 방향성 자체가 다르기 때문이다. Rust는 더 나은 C++을 지향하고, Go는 더 나은 Java를 지향한다고 한다(내 체감상으로는 Java보다는 Python과 비교하는게 더 맞는것 같다). 물론 단순 퍼포먼스만 보자면 Rust가 평균적으로 살~~짝 좋다고 한다 (Golang vs Rust 퍼포먼스 벤치마킹 썰).
Rust와 Go를 비교하는건 다음에 해보기로 하고, 이 글에서는 Rust와 C++로 알고리즘 문제를 풀어보며 내가 느낀 차이점을 비교해 보려 한다.
알고리즘 문제 풀어보기
알고리즘 문제는 백준에 올라와있는 간단한 BFS문제를 선택하였다(2178. 미로탐색). 둘의 코드가 최대한 비슷한 모양이 되도록 하기 위해 C++17으로 작성하였다.
나는 C++도 많이 안 다루어 보았고, Rust도 간단한 문법만 배우고 써본것이라 둘 다 적절한 코딩 스타일과 기능을 사용하지 않았을 수 있으니 감안하고 보면 좋을 것 같다…
약간의 줄바꿈 조절이 있긴 했지만, 둘 다 50줄 정도의 코드가 나왔고, 예상보다 C++과 Rust의 코드 모습이 그렇게 크게 다르진 않았다.
우선은 C++부터 살펴보고, 이를 Rust로 그대로 옮기면서 Rust 컴파일러가 뱉은 에러를 하나씩 살펴보며 Rust가 어떻게 Memory Safety를 제공하는지 생각해 보았다.
C++ 풀이
위 코드는 테스트 케이스는 통과하긴 하는데, 백준에 제출하면 오답으로 뜬다.
Rust 풀이
다음은 Rust로 코드를 옮기면서 겪은 어려움과 느낀점이다.
인풋 받는게 불편함
scanf
같은 함수가 없어서 한줄을 받고 직접 파싱을 해야 했고, string
이 int로 인덱싱이 안되서 as_bytes
함수로 바이트 어레이로 바꾼 다음 한 바이트씩 뽑아야 했다. 다른건 몰라도 인풋받는거 때문에 알고리즘 문제를 푸는데는 C++이 나은것 같다.
메모리 접근에 대해 매우 깐깐함
38, 39번 줄을 보면 visited
, field
배열의 인덱스로 사용되는 nx
, ny
를 usize
로 캐스팅 해서 쓰는것을 볼 수 있다. 이는 Rust가 강제하는 사항으로, 배열의 인덱스는 무조건 usize
타입이어야 한다. 또한 범위를 초과하는 인덱스로 접근하면 바로 크래시가 나며 프로그램을 종료한다고 한다.
알고리즘 문제라는 특수한 상황에서는 그럴일이 거의 없겠지만, 실제 프로그램 개발시에는 저런 문제로 메모리 접근 취약점이 발생할 수 있는데, C++로 풀때는 아무생각없이 signed integer를 인덱스로 사용했는데 Rust가 강제로 잡아주니 뭔가 나쁜 습관을 교정받는 느낌이었다.
값의 소유와 대여
위 코드에서도 나와있는데,
1 | if (0..n).contains(&nx) && (0..m).contains(&ny){ |
부분에서 contains
함수가 nx
, ny
값을 변경하는거도 아닌데 레퍼런스로 값을 받고 있다. 이는 contains함수가 파라미터로 받은 값을 소유하게 되는것이 아니라 단순히 잠깐 빌려쓰기 때문이다. 또한 nx
, ny
는 immutable 하기 때문에, contains 함수는 값을 소유하지도, 변경하지도 않을 것이란것을 컴파일러가 알 수 있다.
소유권이라는 개념을 새로운 키워드 없이 레퍼런스라는 개념 위에 얹어 간단하게 구현한게 아주 신기했다.
그래도 C++과 Rust의 비슷한 점
- 일단 둘 다 표준 라이브러리가 Snake Case로 이름을 짓는다. 그리고 두 코드 모두 STL에 포함된 Deque를 썼는데, 둘의 메소드 이름이 거의 똑같았다 (e.g.,
push_back
). - 포인터와 레퍼런스 개념도 C++와 Rust가 거의 유사한것 같다. 다른점은 Rust에서 포인터(*)는 Unsafe 취급을 받고, 레퍼런스는 사용에 제약(소유, 대여)이 많다는 정도?
- C++17과 비교하면 문법적인 차원에서 큰 차이는 없는것 같다.
Memory Safety가 잘 보장될까?
정말 저런 단순한 아이디어로 Memory Safety가 보장될까? 라고 생각할 수 있다.
그런데 사실 위에 작성한 C++, Rust 코드는 Rust부터 작성하고 C++로 번역한 것인데, 신기한것은 Rust는 정답이라 뜨는데 C++는 몇몇 인풋에 대해 답은 뜨는데 메모리 에러가 뜬다. C++로 작성할때 일부러 메모리 접근에 큰 신경을 안쓰긴 했지만, 실제로 Rust 컴파일러가 이러한 문제점을 잘 잡아주는 것 같다.
흥미로우신 분들은 위 C++코드에서 메모리 문제가 발생하는 원인을 알아보아도 좋을 것 같다.
Summary
내가 느낀 Rust는 한마디로 정의하면 Memory safety가 추가된 Modern C++ 인것 같다.
언어차원의 Tuple, Range 지원 등을 제외하면 C++17에서도 람다함수, destructuring 등등 많은 현대적인 언어 기능을 지원하고 있어 문법적인 측변에서 차이는 그렇게 크지 않은것 같았다.
하지만 Memory Safety 관련해서는 깐깐한 과외선생님을 옆에 두고 코딩하는 느낌을 받았으며, 확실히 Rust 컴파일러가 제공하는 Memory Safety 교정을 받으면 안전한 프로그램을 만드는데 큰 도움이 될 것 같다고 느꼈다.
근데 너무 깐깐해서 크고 중요한 프로젝트가 아니면 그냥 Go 쓰는게 나을것 같다 😏.