프로그래밍 일반

Rust 잠깐 배워 보았어요

오늘아트 2022. 9. 26. 10:43

개요

한 기사에 따르면 Azure CTO가 더 이상 쓰지 말자는 강한 표현까지 써 가면서 C, C++ 메모리 관리의 어려움과 Rust를 쓰는 것의 당위성을 강조했는데요, 물론 CTO를 넘어 빌 게이츠의 할아버지가 말한대도 바로 C, C++ 유행이 확 내려가진 않겠습니다만, 다른 건 몰라도 저는 살짝 쫄리네요.

 

MS 애저 CTO "C·C++ 이제는 그만"

마이크로소프트(MS) 애저의 마크 러시노비치 CTO는 업계에서 C 및 C++ 언어를 더 이상 사용되지 않는 언어로 취급해야 한다고 강조했다.최근 미국 지디넷에 따르면, ...

zdnet.co.kr

 

그래서 이번에는 Rust를 배우는 글을 작성합니다. 글은 독자가 C++ 및 컴퓨터과학(프로그래밍 언어론/컴파일러)에 대한 높은 수준의 이해를 갖고 있다고 가정하고 최대한 간결히 작성했습니다. 빠른 설명을 위해 C++와의 직접적인 비교가 될 겁니다. 중학생 때 C로 입문해 갖고 파이썬 등의 일부 구문조차 비직관적이라고 깠을 정도로 편협한 제가 잘 적응할 수 있을까요?

 

목차

과정은 공식(?)의 자료를 따라갑니다. 엄밀한 문법 규칙은 여기서 다루지 않습니다.

 

Learn Rust

A language empowering everyone to build reliable and efficient software.

www.rust-lang.org

0. 지금 왜 Rust를 배우려는 것인가?

1. 시작하기

2. Rust 변수

  2.1. Mutable, Immutable

  2.2. 타입

  2.3. 튜플

  2.4. 배열

  2.5. 참조

3. 제어문

  3.0. 블록

  3.1. if

  3.2. loop

  3.3. while

  3.4. for

4. 함수

5. 소유권(Ownership)

  5.1. 소유권 규칙

  5.2. 참조자

  5.3. 조각

  5.4. 힙 메모리 할당

6. 구조체

  6.1. 속성(trait)

7. 열거형

8. 제네릭

9. unsafe

  9.1. 포인터

  9.2. extern "C"

  9.3. 전역 변수 수정

후기

 

본문

0. 지금 왜 Rust를 배우려는 것인가?

C/C++는 그 메모리 활용의 자유도로 인하여 접근 위반 등의 메모리 문제를 예방하는 것이 매우 중요하며(가벼운 문제가 세그먼테이션 오류로 인한 크래시, 무거운 문제로 ROP 같은 취약점, 정의되지 않은 행동이 발생할 수 있음) 이는 프로그램이 커지면 결코 쉽지 않은 문제가 됩니다. 그래서 위의 뉴스와 같은 발언도 나오고 여기저기에서 Rust로 모듈을 바꾸고 있는 것이기도 하지요.

 

Rust는 C/C++와 같이 즉시 실행되는 기계어로 컴파일됩니다. (단, 컴파일을 하면 일정량의 런타임팩이 같이 들어갑니다) C와 C++ 수준의 메모리 제어가 가능하면서 메모리 접근 위반을 미리 방지하려면 언어 자체의 스타일이 많이 달라져야 했을 겁니다. 이제부터 확인해 보죠.

 

대체 어떻게 그런다는 것인가? 만 보고자 한다면 5장을 보시면 되겠습니다. 요약하자면 기본적으로 힙 객체에 대해서는 유니크 포인터만을 사용하며 임의 해제는 불가능, 유니크 포인터의 참조자가 유니크 포인터의 스코프 밖으로 나가면 컴파일 오류 발생, 非 const 참조자는 다른 참조자와 활성 영역(스코프와는 다름)이 겹칠 수 없음입니다. (덧붙여 공유 포인터를 사용할 방법도 있습니다만 이는 기본 특성이 아니라 별도의 객체로 구현되어 있습니다.)

 

1. 시작하기

여기서는 "개발 환경 세팅"을 빠르게 짚고 넘어갑니다.

Rust는 rustc라는 컴파일러가 있으며, 보통의 개발은 이것을 포함하여 여러 가지 기능을 가지고 있는 Cargo라는 도구를 사용한다고 합니다.

 

여기에 들어가서 rustup을 받아 주세요. 들어가면 자동으로 여러분의 환경에 맞는 설치 방법이 나옵니다. (예를 들어 Windows에서는 페이지에 나오는 rustup-init.exe를 받아 실행해야 합니다.) 리눅스 환경의 경우 커맨드라인을 통해 설치했다면, 셸을 한 번 껐다가 켜야 실행이 가능해질 수 있는 점 참고하세요.

 

Windows 터미널 기준 대충 이런 식으로 나오는데 엔터 누르면 설치가 시작되며, 완료되면 명령줄에서 즉시 접근할 수 있습니다.

 

그 다음 IDE를 세팅하는 방법은 다양한 게 있지만, 여기서는 가장 무난하게 Visual Studio Code를 사용하겠습니다. 필요하다면 직접 받아 주세요. 이후 rust-analyzer 확장을 설치해 줍니다.

그 다음 루트가 될 폴더를 만들고, 터미널을 열어 그 폴더에서 cargo init을 실행합니다. 다음과 같이 디렉토리 구조가 생겼을 겁니다. 제가 넣은 LICENSE 파일(Unlicense입니다.)을 제외하고요.

 

 

그 다음 cargo run을 하면 필요한 소스가 컴파일되고(기본적으로 루트 디렉토리의 이름과 같습니다.) 곧바로 실행됩니다.

마치 CMake 같죠? cargo 실행 시 아무런 인수를 주지 않는다면 설명이 나오니, 다른 설명이 필요하다면 참고하기 바랍니다. 이 실행 파일은 약 157KB입니다. 릴리즈 모드로 하면 약 153KB이고요. 이는 꽤 큰 값입니다. C/C++로 Hello world를 호출하도록 한 프로그램의 용량은 물론 크게 웃돌며 Win32 빈 창 만들고 보여주는 프로그램까지 가야 151KB가 나옵니다. 참고로 리눅스에서는 같은 프로그램이 3MB 정도까지 가는데, 이게 Rust가 집어넣은 런타임 실행의 크기로 보시면 될 것 같습니다. 이 런타임 때문에 심플하게 어셈블리로 덤프한 다음 분석하는 것이 꽤 어렵습니다. 저야 방법을 모르지만 누군가는 이미 했겠죠.

 

 

참고로 위 내용을 보시면 알겠지만 빌드된 프로그램을 실행하기 위해 Rust 고유의 의존성 라이브러리 같은 걸 받을 필요는 없습니다. (리눅스도 마찬가지) 아무튼 이제 Rust로 뭘 할 준비가 됐으니 들어가 보죠.

 

2. Rust 변수

여기서는 Rust에서 변수와 상수의 특징을 C++와 비교하면서 알아봅니다.

먼저 변수의 값을 확인할 수단이 있으면 좋겠습니다. (재차 강조하지만 이 글에서는 여러분의 높은 C++ 이해도를 가정하므로 출력을 직접적으로 써서 뭔가 보여줄 일이 거의 없을 예정입니다. 필요하다면 직접 테스트해 보기를 권장하고 있다는 의미입니다.) 일단 main.rs 파일을 이렇게 고쳐 봅시다.

fn main() {
    let x=1;
    println!("Hello, {x}!");
}

실행 결과는 Hello, 1! 입니다. 이런 코드도 가능합니다.

println!("{}, {}", x, 'a'); // 출력: 1, a
println!("{0},{1},{0}",x,'a'); // 출력: 1,a,1

python 문자열 형식화를 알고 계신다면 쉽습니다. 모르셨다면 번호가 붙은 버전은 뒤에 전달된 것 중에서 0, 1, 0번째를 각각 출력한다는 말입니다. 참고로 중괄호 문자를 입력하려면 {{, }}가 필요합니다. 참고로 일반 {} 안에는 보통의 리터럴을 넣으면 오류로 취급되므로 리터럴은 아래쪽과 같은 식으로 사용해야 합니다.

 

2.1. Mutable, Immutable

이제 변수에 대해 알아봅시다. 위의 let x=1; 부분이 바로 변수를 선언하는 부분입니다. 이것은 immutable 변수이며, 여기에는 값을 덮어쓸 수 없습니다. 즉 다음 코드는 컴파일되지 않습니다.

let x=1;
x=2;

하지만 다음 코드는 컴파일됩니다.

let x=1;
let x=2;
let x="abcd";

즉 같은 이름의 변수를 재선언하면 이전의 변수는 더 이상 쓸 수 없다는 의미로 볼 수 있습니다. 다음 경우는 약간 다른데요, 변수가 살아 있을 구실이 있습니다.

let x=1; // x에는 1이 바인드되어 있음.
{
let x=2; // x에는 2가 바인드되어 있음.
}
// x에는 1이 바인드되어 있음.

즉 let은 C++의 const와 비슷한데 같은 이름의 재선언이 가능하다는 차이가 있습니다. 변수의 바인드와 관련된 문법이 다르죠. 참고로 같은 스코프에서 재선언으로 변수를 덮었다고 해서 반드시 스코프를 나간 것으로 취급하진 않습니다. 이는 나중에 나오는 Drop을 구현해 보고 직접 판단해 보세요.

 

Mutable한 변수는 기존의 C++ 변수와 비슷하게 사용할 수 있습니다. mut 키워드가 추가로 붙어서 선언됩니다. 당연하지만 이건 동적 타이핑 언어가 아닙니다. 게다가 기본적으로 암시적 캐스트도 지원되지 않습니다.

let mut i=1;
i+=3;
// i="abc"; // 컴파일 오류

 

한편 let에서 자바스크립트 같은 느낌을 받을 수 있는데요, const도 있습니다. const 이름:타입 = 값의 형식을 가집니다. (참고로 타입을 명시하는 것은 let, let mut에서도 할 수 있습니다.) 이는 C++의 constexpr와 같은 역할을 합니다. 다음 코드는 자세한 사항을 몰라도 Rust const는 C++ constexpr와 비슷한 개념인 것을 추론할 수 있습니다.

 

use std::io;
fn main() {
    const i:i32=1;
    let mut g;
    io::stdin().read_line(&mut g);
    //const j:String=g; // 컴파일 오류
}

 

2.2. 타입

정수형 타입은 i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, usize가 있습니다. i8은 부호형 8비트 정수고, u16은 비부호형 16비트 정수인 식입니다. size는 C/C++의 size_t와 비슷한 포지션인데 대상 아키텍처에 따른 부호형/비부호형 정수라고 보시면 되겠습니다.

 

부동소수점 타입은 f32, f64가 있습니다. f80은 없습니다.

 

정수, 부동소수점 리터럴은 C/C++와 약간의 차이가 있습니다.

- 접미사로 타입 이름 자체만 가능합니다.

- 8진 정수 리터럴은 0o로 시작합니다.

- 숫자 사이와 끝에 _가 들어갈 수 있습니다. 맨 앞에는 안 됩니다. 예를 들어 12_3_.012__3이랑 123.0123은 같은 표현입니다. 소수점 바로 뒤에도 _가 들어갈 수 없습니다.

- 문자 리터럴은 정수 리터럴과 호환되지 않습니다. 단, u8에 한하여 b'a'와 같은 아스키 값을 쓸 수 있습니다.

 

진리 타입은 그대로 bool이며 true, false라는 리터럴이 있습니다.

 

문자 타입은 char인데, 유니코드 값을 가지며 모든 문자는 4바이트입니다. 작은따옴표를 이용하여 표현합니다.

 

기본 문자열 타입은 일단은 &str입니다만, 이건 이후에 다루겠습니다. 내부 인코딩은 항상 UTF8입니다. 때문에 Rust char의 배열과는 다릅니다. 자세한 내용은 UTF8을 별도로 알아보세요.

 

2.3. 튜플

Rust는 C#과 비슷한 형식으로 tuple을 제공합니다. 다음 예제를 참고하세요.

fn main() {
    let tup = (500, 6.4, 1);
    let (x, y, z) = tup;
    println!("The value of y is: {y}");
}
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;

mutable로 선언하면 수정도 가능합니다.

let mut x: (i32, f64, u8) = (500, 6.4, 1);
x.0=1;

빈 튜플, 즉 ()은 C/C++ void와 비슷한 포지션이고 unit이라고 불립니다. 한 용례는 아래 함수 장에서 설명합니다. 이건 python의 None과 비슷한 포지션인데, 실제로 Rust에도 None이 있습니다.

 

2.4. 배열

변수 인덱스 접근이 가능한 데이터인 배열은 [ ]를 이용하여 표현합니다. 예제를 참고하세요.

let a = [1, 2, 3, 4, 5];
let first = a[0]; // 1
let second = a[1]; // 2
let b=[3;5]; // [3,3,3,3,3]

역시 값을 수정하려면 mut로 선언해야 합니다. let a[0]=1; 이런 건 안 됩니다. 배열에서 주목해야 할 점은 Rust가 메모리 안전하다고 했으니 한번 런타임 변수로 접근해 보는 것이겠죠. 다음 예제를 돌려 5 이상의 정수를 입력해 봅시다. 지금 자세한 내용은 알 것 없습니다.

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];
    println!("Please enter an array index.");
    let mut index = String::new();
    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");
    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");
    let element = a[index];
    println!("The value of the element at index {index} is: {element}");
}

아마 이런 결과가 나올 겁니다.thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src\main.rs:14:19뭔가 기대했던 것과는 다르군요. 전 a[index]라는 부분에서 컴파일 오류라도 일으킬 줄 알았습니다. Rust가 이것이 메모리 안전하다고 이야기하는 것은 아무래도 C/C++에서는 프로그램 상에서 무효한 데이터에 접근하는 게 반드시 프로그램 깨짐으로 이어지지는 않지만 여기서는 반드시 'panic'된다는 말 같은데요, 이 정도는 C++ 컨테이너에서도 매번 at() 같은 것만 사용한다고 치면 그만입니다.

 

2.5. 참조

지금까지 보았던 모든 변수는 요즘 나오는 언어들과 같은 참조의 형식이 아니라 스택에서 값을 그대로 사용하는 형식입니다. 하지만 Rust에서의 메모리 관리 방식은 다른 언어들과 많이 달라서, 아직 참조를 배울 때가 아닙니다. 바로 보고 싶다면 5장으로 넘어갈 필요가 있습니다.

 

3. Rust 제어문

여기서는 Rust의 제어문인 if, loop, while, for의 문법과 동작을 확인합니다.

 

3.0. 블록

블록은 직접적으로 제어를 하지는 않지만, 이하 내용을 설명할 때 필요한 내용이므로 여기 기술합니다. Rust에서는 블록이 스코프 분리 외에도 표현식으로 취급된다는 특징이 있습니다. (C/C++에서는 식이 아니라 문이었죠?) 표현식의 값은 블록 내 흐름의 마지막에 있는 표현식을 따릅니다. 기본적으로 표현식은 ;가 없는 우측값으로 표현됩니다. 즉 값의 대입은 = 표현식 ;라고 해석하는 게 맞습니다. 아래 예시를 참고하세요. 덧붙이자면 Rust에서 =, +=, -= 등 대입 연산자는 () 표현식으로 취급됩니다. 그래서 a=b=c; 같은 게 안 됩니다.

let x={
   3
}; // x=3;과 동일

let y={
    let z=10;
    z*z
}; // y=100;과 동일

let z={
    4*3;
}; // z=();과 동일

let w={
	let x=100;
    {
    	let y=200;
        x+y
    }
}; // w=300;과 동일

 

3.1. if

기본적으로는 if bool 표현식 { 내용 }의 형식을 가집니다. (다시 강조하지만 암시적 캐스팅은 안 됩니다.) C/C++와 달리 bool 표현식에는 괄호가 없어도 되며(현재 기준으로 있어도 컴파일은 됩니다.)  그 뒤에는 반드시 블록이 있어야만 합니다. 또한 Rust에서 if식은 표현식이기 때문에 다음과 같은 문장이 가능합니다.

let y=true;
let x= if y {1} else {3}; // 당연히 x 타입은 하나로 추론되어야 하니 if, else 뒤의 표현식의 타입이 일치해야 합니다.

else if도 직관적으로 사용할 수 있습니다.

 

3.2. loop

loop {내용}으로 구성되며, 무한 루프입니다. 이 역시 표현식입니다. 무한 루프에서 표현식을 보내는 것은 break 표현식;으로 합니다. 그냥 break;하면 ()로 취급되겠죠? 앞에서처럼 대입을 한다면 모든 탈출 표현식의 타입을 맞춰야 합니다. 예제를 참고하세요.

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

 

3.3. while

while문도 if와 비슷하게 while bool 표현식 {내용} 입니다. break로 나갈 수는 있지만 break 표현식; 은 사용할 수 없습니다.

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

 

3.4. for

for문은 C++의 enhanced for 루프 같이 어떤 범위 상에서 반복하는 데 사용할 수 있습니다.

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

 

4. Rust 함수

Rust에서 함수를 정의하는 기본적 방법을 짧게 설명합니다.

main 함수를 보신 바와 같이 함수의 선언은 fn 키워드를 씁니다. 기본적으로 fn 이름(매개변수들)->리턴타입 { 내용 } 의 형식인데, 다음 예시를 참고하세요.

fn main() {
   pr();
   m2(4);
}

fn pr()/*-> ()*/{
    println!("pr");
    // ()
}

fn m2(x: i32)->i32{
    x*2
}

함수에서 ;가 없는 표현식을 만나면 그것을 리턴합니다. 지금까지 본 것과는 약간 다르게 함수는 리턴 타입을 암시적으로 추론하지 않습니다. ->리턴타입 부분이 없으면 ()를 리턴하는 것으로 취급합니다. ()를 적어도 리턴할 수는 있는데, 리턴문은 기본적으로 해당 흐름의 마지막 문장이어야 합니다. (안 그러면 컴파일 오류)

 

return expr;과 같은 형식도 가능하긴 합니다. 당연히 이건 함수에서만 사용할 수 있고 일반 블록에서 사용하면 그것이 있는 함수에서 리턴하게 됩니다.

 

함수 안에 함수를 선언하는 것도 가능합니다. 함수를 쓰는 것은 같은 스코프에 있기만 하면 순서에 상관 없이 가능합니다. 바로 위의 예제만 해도 main이 pr보다 먼저 나왔는데 사용이 되죠. 

 

현재 Rust 자체에는 함수 매개변수에 기본값을 주는 방법이 없습니다. 하지만 원한다면 시도해 볼 만한 게 몇 있긴 하니 필요하면 검색해 보세요.

 

5. 소유권(Ownership)

Rust에서 메모리 관련 오류를 미리 알아내기 위한 정책을 설명합니다.

 

5.1. 소유권 규칙

Rust로 만든 프로그램의 메모리는 소유권과 컴파일러가 검사하는 일련의 규칙의 시스템으로 관리됩니다. 규칙을 위반하면 컴파일이 되지 않습니다. 그래서 이 소유권 시스템은 런타임에 성능을 잡아먹지 않습니다.

 

규칙은 기본적으로 이렇습니다.

- Rust의 모든 값은 소유자(owner)가 있습니다.

- 값마다 소유자는 하나만 있을 수 있습니다.

- 소유자가 스코프에서 더 이상 없으면 값은 없는 것으로 취급합니다. (해제됨)

 

String 타입을 예로 메모리 관리를 간단히 알아봅시다. Rust의 String은 C++의 std::base_string과 마찬가지로 힙 데이터를 할당하고 해제합니다. 다음 코드를 상정해 봅시다.

 

let s1 = String::from("hello");
let s2 = s1;

 

C++ string의 경우 코드를 위와 같이 쓴다면 깊은 복사 생성, 즉 힙의 데이터를 할당하여 똑같은 값을 줍니다. 하지만 Rust의 String은 내부적으로 얕은 복사를 진행합니다. 이 경우 힙의 영역을 가리키는 스트링 포인터가 복사되고, 이때 힙의 영역에 대한 소유자는 둘이 되니 2번째 규칙이 어겨집니다. 즉 그렇게만 구현했다면 컴파일 오류가 발생하겠네요. 그래서 실제로는 s1은 더 이상 주인이 아니게 되면서 컴파일이 가능합니다. 때문에 위 코드에 이어 s1의 값을 출력하는 등 접근하려 한다면 컴파일 오류가 발생합니다. s1의 값은 이동되었기 때문에 지금 사용할 수 없다는 내용입니다. 이는 C++의 이동 개념과도 비슷한 면이 있습니다. 다만 String의 깊은 복사가 필요하다면 clone 멤버함수를 이용할 수 있습니다. 물론 이때는 값 주인이 겹치지 않아 이동할 필요가 없으니 s1, s2 모두 사용할 수 있습니다.

let s2 = s1.clone();

그럼 이 이동 개념 덕분에 일단 허상 (dangling) 포인터를 컴파일 시점에 방지할 수 있겠네요. s2 객체에서 메모리가 해제된 후든 전이든 s1에서 데이터에 접근할 수 없도록 프로그램 구조를 강제하는 겁니다. 덤으로 기본값이 깊은복사가 아닌 이동인 것은 성능 때문으로, 복사가 꼭 필요한 경우에만 한다는 말입니다.

 

기본적으로 메모리의 값 그대로를 복사할 수 있는 타입은 Copy라는 속성(trait)이 있으며, 이 속성은 스코프를 벗어나면 데이터를 해제하는(RAII 패턴과 같은 식) Drop 속성과 공존이 불가능합니다. 간단히 말해 소유권은 사실상 힙 메모리를 위한 규칙이다 생각하면 됩니다. 정수/부동소수점/진리/문자/그들로만 구성된 튜플 등이 모두 스택에 할당됩니다.

 

이는 함수 매개변수로 전달될 때에도 마찬가지입니다. 기본적으로 = 자체가 이동을 지시하니 관리가 어떤 느낌인지 감이 올 겁니다.

fn main() {
    let s1 = gives_ownership();         // (1) 함수 호출
                                        // (3) some_string으로부터 s1으로 소유권 이동. some_string은 나가지만 무효 객체이므로 해제되는 메모리는 없음
    let s2 = String::from("hello");     // (4) s2가 스코프에 들어옴
    let s3 = takes_and_gives_back(s2);  // (5) 함수 호출
                                        // (7) s3이 스코프에 들어오지만 나가는 것 없음
} // s1, s2, s3이 스코프에서 없어지며 s1, s3은 해제되고 s2는 무효

fn gives_ownership() -> String {
    let some_string = String::from("yours"); // (2) some_string이 스코프에 들어옴
    some_string                              // 리턴
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // (6) a_string으로 소유권 이동
    a_string  // 리턴
}

 

하지만 함수에 데이터를 보여주기만 하는 경우도 많은데, 그때마다 일일이 원래 해야 했던 것과 함께 리턴해야 한다면 굉장히 귀찮은 일이 되지 않을 수 없습니다. 뭐만 하면 컴파일 오류가 발생할 거고 이는 좋은 설계가 아니죠. 그래서 Rust는 참조자도 제공합니다.

 

5.2. 참조자

참조자는 타입 앞에 &를 붙여 표현되고 어떤 변수에 대한 참조를 전달하고자 한다면 변수 이름 앞에 &를 붙입니다. 5.1을 잘 이해했다면 알려드리지 않아도 참조자는 스코프에서 사라져도 해제에 관여하지 않는 걸 눈치 채셨을 겁니다. (통상 C++ 참조자 역시 스코프에서 나가도 소멸자가 호출되지는 않죠.) 뭔가 수정이 들어가는 동작을 원한다면 mutable 참조자를 써야 합니다. 타입 A의 보통 참조자는 &A 타입이고 mutable 참조자는 mut &A 타입입니다. 당연히 mutable 참조자는 mutable 변수에서만 얻을 수 있고 &mut 변수이름으로 얻을 수 있습니다.

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

mutable 참조자는 같은 객체에 대한 다른 mutable 참조자와 공존할 수 없습니다. 즉 이런 코드는 컴파일 허용되지 않습니다.

let r1 = &mut s;
let r2 = &mut s;

이유는 데이터 레이스를 방지하기 위함인데, 이는 레이스 조건이랑 비슷한 개념입니다. 다음 3개 조건이 모두 만족할 때 데이터 레이스가 있다고 합니다.

- 2개 이상의 포인터가 같은 데이터에 동시에 접근함

- 최소 하나 이상의 포인터는 데이터를 쓰는 데 사용

- 데이터 접근을 위한 동기화 방식이 마련되어 있지 않음

 

이것 때문에 mutable 참조자의 활성 범위와 같은 객체의 다른 참조자의 활성 범위가 겹치면 컴파일 오류가 발생합니다. 활성 범위는 스코프보다는 빡빡한 개념입니다. 활성 범위를 모른다면 여기를 확인해 주세요. 아래 코드는 컴파일 가능합니다.

 

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// r1, r2는 이제부터 사용되지 않으므로 없는 취급 해도 됨

let r3 = &mut s;
println!("{}", r3);

그리고 참조자는 원본 객체의 스코프 밖으로 나갈 수 없습니다.

C/C++와 비슷하게 객체 이름 앞에 *를 붙이면 역참조가 가능합니다. 다만 이미 참조자 자체가 해제만 없는 원본 객체와 같은데 대체 언제 그걸 쓸 일이 있는가 하면 저도 모르겠습니다. 알게 되면 추가하겠습니다.

 

5.3. 조각(slice)

배열과 같이 연속적 주소 공간에 배치된 데이터의 일부를 참조하는 참조자를 말합니다. 그냥 포인터를 쓰는 것과 다른 점은 아무래도 길이 정보가 있다는 점이 되겠죠. 다음을 참고하세요.

 

fn main() {
    let x="string";
    let y=&x[0..3];
    for c in y.chars(){println!("{c}");} // 출력 결과:  s, t, r 각각 한 줄에 하나씩 출력
}

 

5.4. 힙 메모리 할당

힙 할당의 경우 기본적으로 Box라는 스마트 포인터 타입을 이용하여 할 수 있습니다.

let x=Box::new(5); // i32 단일 할당
let mut y=Box::new([b'\0';16]); // u8 배열 할당

역참조는 *x와 같이 하면 됩니다. 물론 앞서 본 것들과 같이 스코프를 나가면 해제하도록 되어 있습니다.

 

6. 구조체

Rust의 객체 지향 관련 요소 중 하나인 구조체의 선언과 사용 방식을 확인합니다.

 

Rust에서의 구조체는 C++에서의 구조체와 동일한 역할을 합니다.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
fn main() {
    let mut user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };
    user1.email = String::from("anotheremail@example.com");
}

그리 어렵지 않게 이해할 수 있을 겁니다. C/C++ 구조체 정의문에는 ;가 필요하지만 여긴 아닙니다. 객체 생성 시 저기서 초기화하는 변수의 이름이 멤버 이름과 같다면, email:email 할 필요는 없고 그냥 email이라고만 적어도 됩니다. 그리고 Rust의 현 버전에는 class라는 예약어가 없습니다.

 

객체의 전체 혹은 일부 내용 복사를 아래와 같이 할 수도 있는데, 이건 앞서 알아본 대로 Copy 속성이 없는 멤버는 복사가 아니라 이동된다는 점에 주의하시기 바랍니다.

// 이어서
let user2=User{
        active:false,
        ..user1  
    };

튜플에 구조체 이름을 붙이는 것도 가능합니다. 아래 예시를 참고하세요. 참고로 멤버 접근은 튜플이랑 똑같이 .0, .1, .2, ...로 합니다.

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let mut origin = Point(0, 0, 0);
    origin=black; // 컴파일 오류
}

빈 튜플과 같은 구조체도 만들 수 있습니다. 아까 함수 순서가 바뀌어도 컴파일이 가능했던 데서 눈치를 채셨겠지만 Rust도 현대에 나온 언어들처럼 forward declaration이 필요 없습니다. 그러니 아래 같은 코드를 그거랑 헷갈리면 안 돼요.

struct AlwaysEqual; // struct AlwaysEqual(); 도 가능

fn main() {
    let subject = AlwaysEqual;
}

멤버 함수는 이렇게 구현합니다.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
    fn one(&self)->u32{
        1
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

같은 구조체의 함수를 여러 impl 블록에 나누어 담아도 됩니다. Python마냥 self 참조가 없는 함수도 C++ 정적 함수처럼 만들 수 있고, 다음과 같이 생성기 함수를 만들 수도 있습니다.

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

정보은닉은 기본적으로 같은 모듈 내에서는 안 됩니다. 모듈은 namespace마냥 아래와 같은 식으로 만듭니다. 하지만 정보은닉 기능도 있으니 C++로 치면 본체는 아무래도 상관 없는 순수 정보은닉용 클래스라고 볼 수 있겠네요. (Rust에서는 구조체 안에 다른 구조체나 모듈이 선언될 수 없다는 차이가 있음) 아래 예시를 참고하세요.

mod se{
    pub struct Rectangle { // 모듈 밖에서 Rectangle을 사용할 수 있음. pub이 없으면 se 안에선 사용할 수 음
        width: u32, // 기본적으로 private
        pub height: u32, // 
    }
    
    impl Rectangle { 
        fn area(&self) -> u32 { // 모듈 밖에서 area를 사용할 수 없음
            self.width * self.height
        }
    }
}

fn main() {
    let mut rect1 = se::Rectangle { // 오류 없음. 위에 use se::Rectangle 하면 그냥 Rectangle이란 이름도 사용 가능
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area() // 오류
    );
    rect1.width=20; // 오류
    rect1.height=30; // 오류 없음
}

즉 기본값이 숨김이며 단위는 모듈(파일이 모듈을 정의하지 않은 경우 파일 단위로 모듈이 끊어집니다). 변수나 함수, 클래스 자체에 대하여 선언하기 전 앞에 pub를 붙이면 모듈 밖으로 공개가 됩니다.

 

Rust에는 상속의 개념이 없습니다. 다형성 구현에 대한 힌트는 바로 아래 '속성' 부분과 바로 아래 '열거형' 부분에서 다루었고, 공통 값 공유 같은 경우는 Rust에서 권장하지 않는 바라고 합니다. 해당 사이트에 따르면 필요 이상으로 많은 코드를 클래스 간 공유할 위험이 있다고는 하는데 글쎄요, IS-A 관계를 만족하는데 필요 이상으로 많은 코드를 공유하게 되는 경우가 있나? 그리고 그 정도로 까다로운 사람들이 같은 클래스 객체에 대해서 정보은닉도 직접적으로 제공 안 하나?

 

6.1. 속성(trait)

5장에서 Copy 속성과 Drop 속성에 대해 이야기한 바 있습니다. 이러한 속성을 구현하는 것이 다형성과도 분명 관련이 있습니다.

 

속성은 trait라는 키워드로 정의할 수 있습니다. 아래에 직접 정의한 예시를 확인하세요.

trait Print{
    fn pr(&self);
    fn pr2(&self);
}

struct vec2{
    x: f32,
    y: f32
}

impl Print for vec2{
    fn pr(&self){
        println!("{} {}",self.x,self.y)
    }
    fn pr2(&self){
        println!("{}",self.x);
        println!("{}",self.y)
    }
}

fn main() {
    let x:vec2=vec2{x:1.,y:2.};
    x.pr();
    x.pr2();
}

이건 아직 다형성이 아닙니다. 이 정도는 보통 함수를 정의하는 것만으로도 할 수 있어요. 다형성은 println! 처럼 여러 타입을 같은 소스 코드로 처리하는 게 되겠죠.

fn print2(x: &dyn Print){
    x.pr();
    x.pr2();
}

fn main() {
    let x:vec2=vec2{x:1.,y:2.};
    print2(&x);
}

vec2가 Print를 구현했으므로 dyn Print (trait는 매개로 받을 때 dyn이 앞에 꼭 있어야 합니다)의 참조를 받는 곳에 &x를 줄 수 있습니다. (그냥 x는 줄 수 없습니다) 키워드가 dyn인 걸 보면 알겠지만 동적 디스패치라고 합니다.

 

Drop은 구현한대도 메모리 해제를 직접 관리하진 않습니다. 그저 소멸 시 호출되는 함수를 구현할 뿐입니다. Copy도 마찬가지입니다. 단지 Copy가 붙지 않으면 객체가 스택에 있든 힙에 있든 '이동'된다는 사실만 주의하면 됩니다. 이는 그냥 다음과 같이 붙이고 더 필요한 정보가 있다면 문서를 확인하세요.

#[derive(Clone,Copy)] // 이제 =로 옮기면 주인 이동되는 게 아니라 데이터가 복사됨
struct vec2{
    x: f32,
    y: f32
    // , z: String : 오류. 힙 데이터는 Copy를 허용하지 않음
}

 

trait 생성 시 함수를 넣음으로써 기본 구현을 정할 수도 있습니다.

 

* Drop은 impl로만 구현할 수 있고 derive로는 안 됩니다.

 

7. 열거형

Rust의 열거형은 A is B 관계를 표현하는 데에도 사용할 수 있습니다. 그 방식은 C에서 다형성을 구현하는 방식과도 유사하며, 그 관련 문법을 설명합니다.

 

열거형은 C/C++ 열거형과 비슷한 역할도 수행할 수 있지만, Rust에서는 더 많은 기능이 있습니다.

일단 이게 우리가 생각하던 기본 기능입니다.

enum IpAddrKind{
    V4,V6 // V4=0, V6=0이라고 주면 컴파일 오류
}
fn main(){
let x=IpAddrKind::V4;
}

내부적으로 V4는 0, V6는 1의 값을 가집니다. 그러나 C/C++ 열거형과 달리 Rust 열거형은 다른 이름이 같은 값을 가질 수 없습니다. 한편, 여기서는 열거형의 값들이 정수가 아닌 다른 타입을 가질 수도 있으며 각 이름이 서로 다른 타입을 가질 수도 있습니다(튜플/구조체/열거형도). 내부적으로 enum(정수)에 공용체(union)를 붙인 것 같이 되어 있나 봅니다.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

단순히 보면 여러 종류의 속성일 수 있는 걸 한 타입으로 쓴다는 의의가 있긴 할 텐데요, 그냥은 멤버에 접근을 못 합니다. 그럼 이걸 어디다가 쓰라고 이런 기능을 만들었을까요? 멤버에 접근하려면 match 표현식으로 먼저 이름을 파악해야 합니다.

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

안 봐도 C++ variant스럽네요. 애초에 저기서 이걸 이용해서 보여주는 예시가 C++ optional과 같은 역할의 Option<T>입니다. 내부적으로는 점프 테이블(switch 같은 것)로 타입을 찾게 구현되어 있을 것 같고 여기 match 역시 보통 정수로도 사용할 수 있습니다.

아래 코드의 동작을 확인하고 필요한 내용을 이해하기 바랍니다.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        match self {
            &Message::ChangeColor(_,2,_) => {println!("2");},
            &Message::ChangeColor(ref a,_,_) => {println!("{}",a);},
            &Message::Quit=>{},
            _ => {} // default, 즉 나머지 모두를 다루는 케이스
        }
    }
}

fn main(){
    let a=Message::ChangeColor(1, 2, 3);
    a.call();
}

당연히 값을 수정하려면 self부터 시작해서 싹 mut을 붙여야 합니다. 이런 식으로요.

fn call(&mut self) {
        match self {
            &mut Message::ChangeColor(_,2,_) => {println!("2");},
            &mut Message::ChangeColor(ref mut a,_,_) => { *a=3; println!("{}",a);},
            &mut Message::Quit=>{},
            _ => {}
        }
    }

스위치문인 만큼 이를 통해 동적 다형성을 구현할 수도 있겠는데 그다지 하고 싶진 않네요.

if let으로 한 가지와 그 외의 케이스를 다룰 수도 있는데, 이 코드를 참고하세요.

let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {:?}!", state);
    } else {
        count += 1;
    }

 

8. 제네릭

이런 저런 언어를 보셨다면 알겠지만 제네릭은 C++의 템플릿과 비슷한 포지션입니다. C# 식으로 간단한 편이니 몇 가지 예제만 참고하세요.

struct arr<T, const S:usize>{
    x: [T;S]
}
fn main() {
    let mut a=arr::<i32,3>{x:[0;3]};
    let mut y=arr{x:[1.0;4]};
}

멤버 및 일반 함수 정의에서도 똑같습니다. 다만 Rust는 템플릿 특수화가 없고 함수 오버로드도 없기 때문에, 안에서 직접 조건문을 줘야 합니다. 

impl<T, const S:usize> arr<T, S>{
    fn print(&self){
        println!("{S}");
    }
}
impl arr<i32, 3>{ // arr<i32,3>에만 이 함수가 생김. 근데 위 함수와 공존할 수 없음
    fn print(&self){
        println!("S");
    }
}

 

9. unsafe

이름대로 안전이 부족하지만 성능 등을 위해 더 세밀한 제어를 할 수 있는 기능입니다. 이하 기능들은 unsafe 블록을 열어 사용할 수 있습니다. 함수/trait 등의 앞에 unsafe 키워드를 붙여 사용할 수도 있습니다. 메모리 관련 트러블이 있을 때 있더라도 unsafe에서 찾으라는 말이 되겠죠.

 

9.1. 포인터

*const T 혹은 *mut T라는 타입을 사용할 수 있습니다.

fn main() {
    let mut b=[1,2,3,4];
    unsafe{
        let mut pb = &mut b[0] as *mut i32;
        let pa:usize = pb as usize;
        pb = (pa + 4usize) as *mut i32;
        // pb+=1 불가능
        *pb *= 4;
    }
    println!("{}",b[1]); // 출력: 8
}

주석을 보시면 알겠지만 포인터 산술 같은 건 지원되지 않습니다. 하지만 usize와 포인터 간의 변환은 가능합니다. 저 4 자리에 훨씬 큰 수를 넣으면 당연히 오류가 발생할 겁니다. 이 포인터의 의의는 Rust의 소유 규칙에 구애받지 않는다는 겁니다.

 

9.2. extern "C"

외부 라이브러리의 함수를 사용하려면 unsafe 블록을 써야만 합니다. C 바이너리 링크는 여기선 자세히 안 다루지만 이걸 참고하세요. 

관련 문서: https://doc.rust-lang.org/stable/cargo/reference/build-scripts.html#cargorustc-link-lib

Rust 빌드 결과물을 C에서 링크: https://www.greyblake.com/blog/exposing-rust-library-to-c/

 

9.3. 전역 변수 수정

Rust에서는 static 키워드로 전역 범위에 변수를 만들 수 있는데, 여기에서 값을 읽는 건 가능하지만 쓰는 건 불가능합니다. 앞에 나온 레이스 때문이겠죠 아마. 이게 unsafe 블록 안에선 가능합니다.

 

후기

사실 표준 라이브러리를 배제하더라도 Rust의 특징에 대해서 설명하지 않은 부분이 많이 있습니다. 대표적으로 println! 매크로가 뭔지도 설명하지를 않았죠. 그래도 C++를 아는 입장에서 보았을 때 최소한 Rust와 C++는 이런 차이가 있구나를 잘 파악할 수 있는 시간이 되었으면 좋겠습니다.

본론으로 들어가서, Rust 자체가 어떻게 느껴졌는지를 이야기해야겠군요.

- 결국 Rust의 그 정체성은 컴파일 시 검사하는 메모리-스코프-활성 범위 규칙이 중심이 됩니다. 인덱스 초과는 런타임에 검사되고, 규칙에 의해 허상 포인터는 unsafe 코드를 제외하면 존재할 수 없습니다. 다만 C라면 몰라도 C++에서 장치적으로 그런 것을 막는 것이 Rust보다 훨씬 어렵다?라면 잘 모르겠습니다. 그러므로, 어차피 정답은 돈을 주는 곳(회사)에서 시키는 대로지만, 그런 당연한 걸 제외하고 저 개인이 Rust로 옮겨야지! 할 정도의 매력은 아니라고 다가왔습니다.

- 언어 자체 규칙(위의 메모리 규칙을 제외한, let mut 등을 비롯한 전반)은 개인적으로 좋아하지는 않지만, 객관적으로 나쁘지는 않을 거라고 생각했습니다. (기준은 기존에 C, C++를 하던 사람입니다.)

- 상속이 없다는 점에서 객체지향 프로그래밍은 귀찮습니다. trait에는 속성치가 못 들어가며, 위에서도 언급했지만 A is B를 만족하는 경우 A가 B에서 상속받는 게 편하고 과다 공유에 대한 문제를 상상하기가 어려운 것 같은데..

 

단적으로 말해 "현대의 각종 언어와 비슷하게 사용할 수 있으면서 중간 언어와 쓰레기 수집이 없고 런타임은 작으며 더 고성능이고 메모리를 현대의 각종 언어보다 자유롭게 사용할 수 있다"라는 말은 맞다고 생각합니다. 그런데 메모리 공유를 최대한 막아 놓고 다른 방법을 생각해 보라고 컴파일 단계에서 막는 언어 특성은 글쎄.. 이런 제한 아래라면, 저 같으면 많은 객체(e.g. 3D 모델, 2D 텍스처, ...)를 한 곳에 할당해 두고 읽기 전용으로 참조자(or 인덱스)를 쓰게 될 텐데요. 사실 C++에서도 많이 쓰이고 있는 방식 아닌가?

 

다음 둘 중 하나 같습니다. 제가 못 하는 편이었다면 제가 Rust로 넘어가도 이렇게 사용하던 방식이 바뀌지 않았겠죠. 잠재적 성능은 똑같이 안 나올 겁니다. 하지만 제가 잘 하는 편이었다면 위의 말이 맞을 가능성이 높죠. 그럼 이미 충분히 위험하지 않으니 Rust로 넘어갈 필요가 없을 거고요. 물론 프로그래밍을 할 때 사람 머리는 믿을 수 없다는 거 알고 있긴 한데 처음에 잘 닦아 놓으면 사실 개인 입장에선 괜찮단 말이죠.

 

진짜 짧게 결론을 정리하면 여러분이 C/C++ 프로그래머라면 Rust를 배워는 두거나, 정말 최소한으로 위의 소유권 규칙 정도는 알아 두는 걸 권장합니다. 위 CTO 말대로 Rust가 널리 사용되는 날이 안 오더라도 그런 규칙의 언어가, C++의 아종인 C**가 됐든지 C++의 차후 버전의 새로운 빌트인 타입이 나오든지 언젠가는 쓰일 것 같아요. 인간은 항상 같은 실수를 반복하고 회사(또는 원로 엔지니어)는 그걸 모를 리 없기 때문이죠.