벡터

벡터는 러스트에서 가장 널리 사용되는 자료형 중 하나로, 여러 개의 값을 하나로 묶어서 사용할 수 있습니다. 벡터의 특징은 길이를 런타임에 동적으로 변경 가능하다는 점입니다. 이러한 특징 때문에 런타임에서는 값이 힙 영역에 저장됩니다.

벡터 선언

벡터의 선언은 두 가지로 가능합니다. 첫 번째는 Vec 구조체의 from 메소드를 사용해 배열로부터 벡터를 만드는 방법입니다. 두 번째는 vec! 매크로를 사용해 벡터를 만드는 방법입니다. 값을 직접 입력해 벡터를 만드는 경우, 매크로를 사용하는 방법이 좀더 간결합니다. 이때 컴파일러가 원소의 값으로부터 타입을 추론할 수 있기 때문에 타입을 명시해 주지 않아도 됩니다.

fn main() {
    let vec1 = Vec::from([1, 2, 3]);
    let vec2 = vec![1, 2, 3];
}

비어 있는 벡터를 선언하는 경우는 원소로부터 타입을 추론할 수 없기 때문에 반드시 타입을 명시해야 합니다.

fn main() {
    let vec3: Vec<i32> = Vec::new();
    let vec4: Vec<i32> = vec![];
}

벡터 원소 접근하기

벡터의 원소는 인덱스(index)를 사용해 접근할 수 있습니다. 두 번째 원소 2 를 인덱스로 접근해 변수 num 에 할당하고, 출력하는 예제를 만들어 보겠습니다. 먼저 파이썬 코드는 다음과 같습니다.

vec1 = [1, 2, 3]
num = vec1[1]

print(num)

실행 결과

2

동일한 내용의 러스트 코드는 다음과 같습니다.

fn main() {
    let vec1 = vec![1, 2, 3];

    let num = vec1[1];

    println!("{}", num);
}

실행 결과

2

벡터에 값 추가하기

벡터를 선언하고 값을 추가해 보겠습니다. 먼저 파이썬에서 벡터와 비슷한 리스트로 같은 내용을 구현하면 다음과 같습니다. 리스트의 마지막에 4, 5, 6을 추가합니다.

vec1 = [1, 2, 3]
vec1.append(4)
vec1.append(5)
vec1.append(6)

print(vec1)

실행 결과

[1, 2, 3, 4, 5, 6]

마찬가지로 벡터의 마지막에 값을 추가해 보겠습니다. push 메소드를 사용하면 원소를 벡터 마지막에 하나씩 추가할 수 있습니다. 주의해야 하는 점은 벡터 vec1 이 변경되기 때문에 처음에 vec1을 가변 변수로 선언해야 한다는 것입니다. 마지막으로, 벡터를 프린트할 때는 디버그 모드를 사용해야 합니다. 따라서 서식을 "{:?}"로 사용해야 합니다.

fn main() {
    let mut vec1 = vec![1, 2, 3];

    vec1.push(4);
    vec1.push(5);
    vec1.push(6);

    println!("{:?}", vec1);
}

실행 결과

[1, 2, 3, 4, 5, 6]

벡터에서 값 삭제하기

이번에는 리스트 [1, 2, 3] 에서 마지막 원소 3을 제거한 다음, 맨 앞의 원소 1을 제거해 보겠습니다. 파이썬의 pop 메소드는 실행 시 원소를 제거하고 제거된 값을 리턴합니다.

vec1 = [1, 2, 3]
num1 = vec1.pop()
num2 = vec1.pop(0)

print(num1, num2, vec1)

실행 결과

3 1 [2]

러스트는 pop 메소드에 인덱스를 넣을 수 없고, 무조건 마지막 원소가 제거됩니다. 마지막 원소가 아닌 다른 원소를 제거하려면 remove 메소드에 인덱스를 넣어야 합니다. 러스트의 popremove 모두 원소를 제거하고, 제거된 원소를 리턴합니다.

fn main() {
    let mut vec1 = vec![1, 2, 3];

    let num1 = vec1.pop().unwrap();
    let num2 = vec1.remove(0);

    println!("{} {} {:?}", num1, num2, vec1);
}

실행 결과

3 1 [2]

데크

참고로 파이썬의 리스트와 러스트의 벡터 모두 맨 앞의 원소를 제거하는 데 시간 복잡도가 $O(n)$ 만큼 소요되기 때문에 맨 앞에서 원소를 자주 제거해야 한다면 데크(deque)를 사용하는 것이 좋습니다. 파이썬은 collections 모듈의 deque 를 사용합니다.

from collections import deque

deq = deque([1, 2, 3])
print(deq.popleft())

실행 결과

1

러스트에서는 VecDeque를 사용합니다.

use std::collections::VecDeque;

fn main() {
    let mut deq = VecDeque::from([1, 2, 3]);
    println!("{}", deq.pop_front().unwrap());
}

실행 결과

1

배열

배열 선언

배열(array)이란, 같은 타입의 값이 모여 있는 길이가 고정된 자료형입니다. 파이썬에서 비슷한 내장 자료형은 없지만, 넘파이(numpy)의 배열(array)가 가장 이와 유사합니다. 넘파이는 내부적으로 C로 구현된 배열을 가지고 있고, 파이썬에서 이 배열의 값을 꺼내서 사용하는 방식으로 동작합니다. 넘파이 배열을 이용해 열두 달을 나타내면 다음과 같습니다.

import numpy as np

months = np.array(
    [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
    ]
)
print(months)

실행 결과

['January' 'February' 'March' 'April' 'May' 'June' 'July' 'August'
 'September' 'October' 'November' 'December']

full 함수를 사용하면 배열을 간단하게 한 번에 초기화할 수 있습니다.

nums = np.full(5, 3)
print(nums)

실행 결과

[3 3 3 3 3]

러스트의 배열의 길이는 처음 선언된 이후 변경할 수 없습니다. 배열을 사용하면 벡터와 다르게 메모리가 스택 영역에 저장되기 때문에 빠르게 값에 접근할 수 있다는 장점이 있습니다. 이때 배열의 원소들은 모두 같은 타입이어야 합니다.

배열의 선언은 대괄호 안에 콤마로 구분된 값을 나열합니다.

fn main() {
    let months = [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
    ];
    println!("{:?}", months);
}

실행 결과

["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]

러스트에서도 편리한 배열 초기화를 지원합니다. [3; 5] 와 같이 표기하면 숫자 3을 5번 나열하라는 의미입니다.

fn main() {
    let nums = [3; 5];
    println!("{:?}", nums);
}

실행 결과

[3, 3, 3, 3, 3]

원소 참조

넘파이 배열의 원소들은 인덱스를 통해 접근이 가능합니다.

import numpy as np

nums = np.full(5, 3)
nums[1] = 1
print(nums)

실행 결과

[3 1 3 3 3]

러스트 배열도 동일합니다. 이번에는 배열 원소를 수정해야 하기 때문에 nums 배열을 가변 변수로 선언합니다.

fn main() {
    let mut nums = [3; 5];
    nums[1] = 1;
    println!("{:?}", nums);
}

실행 결과

[3, 1, 3, 3, 3]

넘파이 배열의 길이보다 큰 값을 참조하려고 하면 에러가 발생합니다.

import numpy as np

nums = np.full(5, 3)
print(nums[5])

실행 결과

Traceback (most recent call last):
  File "/Users/code/temp/python/main.py", line 4, in <module>
    print(nums[5])
IndexError: index 5 is out of bounds for axis 0 with size 5

러스트 코드는 컴파일 시 인덱스가 범위를 벗어난다는 에러가 발생합니다.

fn main() {
    let nums = [3; 5];
    println!("{}", nums[5]);
}

실행 결과

Compiling rust_part v0.1.0 (/Users/code/temp/rust_part)
error: this operation will panic at runtime
 --> src/main.rs:3:20
  |
3 |     println!("{}", nums[5]);
  |                    ^^^^^^^ index out of bounds: the length is 5 but the index is 5
  |
  = note: `#[deny(unconditional_panic)]` on by default

error: could not compile `rust_part` due to previous error

하지만 이렇게 미리 참조할 배열 인덱스를 컴파일러가 알 수 없는 경우, 런타임에 에러가 발생할 수 있기 때문에 주의해야 합니다.

fn main() {
    let nums = [3; 5];
    for i in 0..nums.len() + 1 {
        println!("{}", nums[i]);
    }
}

실행 결과

3
3
3
3
3
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:4:24
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

배열은 벡터와 자주 비교되는데, 데이터의 길이가 컴파일 타임에 정해지는 경우에는 배열을, 데이터의 길이가 런타임에 정해지는 경우에는 벡터를 사용합니다.

튜플

튜플은 프로그래밍에서 가장 대표적인 열거형 자료형으로, 값들을 순서대로 나열해 저장하는 데이터 구조입니다. 파이썬과 러스트 모두 튜플 자료형을 가지고 있습니다.

튜플 선언

파이썬의 튜플은 소괄호 안에 콤마로 구분된 값을 넣어서 선언합니다.

tup1 = (0, 0.1, "hello")
tup2 = (1, 1.01, "bye")

_, y, _ = tup2

print(f"tup1 has {tup1} and the value of y is {y}")

실행 결과

tup1 has (0, 0.1, 'hello') and the value of y is 1.01

러스트의 튜플도 소괄호 안에 콤마로 구분된 값을 넣어서 선언합니다. 변수의 타입을 컴파일러가 추론하는 것처럼 튜플의 타입도 컴파일러가 추론하기 때문에 타입을 명시할 필요가 없습니다. 하지만 타입을 직접 명시해도 상관없습니다.

fn main() {
    let tup1 = (0, 0.1, "hello");
    let tup2: (i32, f64, &str) = (1, 1.01, "bye");

    let (_, y, _) = tup2;

    println!("tup1 has {:?} and the value of y is: {}", tup1, y);
}

실행 결과

tup1 is (0, 0.1, "hello") and the value of y is: 1.01

원소 참조

파이썬에서 튜플 원소를 참조하려면 인덱스를 넣으면 됩니다.

tup1 = (0, 0.1, ("hello", "world"))

print(tup1[2][0], tup1[2][1])

실행 결과

hello world

러스트에서 튜플 원소의 참조는 약간 특이한 방식으로 합니다. 튜플 이름 뒤에 점(.)을 붙이고 그 뒤에 인덱스를 입력합니다. 만일 다중 튜플인 경우, 점을 한번 더 찍고 인덱스를 입력하면 됩니다.

fn main() {
    let tup1 = (0, 0.1, ("hello", "world"));

    println!("{} {}", tup1.2 .0, tup1.2 .1);
}

실행 결과

hello world

튜플 불변성

파이썬에서의 튜플과 러스트의 튜플은 차이점이 있는데 바로 불변성입니다. 파이썬의 튜플은 한 번 선언되면 원소의 내용을 바꾸거나, 튜플의 크기를 변경할 수 없습니다.

tup1 = (0, 0.1, "hello")

x = tup1[0]
_, y, _ = tup1

x = 1
y = 1.1

print(tup1, x, y)

tup1[0] = 3

실행 결과

(0, 0.1, 'hello') 1 1.1
Traceback (most recent call last):
  File "main.py", line 11, in <module>
    tup1[0] = 3
TypeError: 'tuple' object does not support item assignment

마찬가지로 러스트의 튜플도 한 번 선언되면 크기를 변경할 수 없지만, 원소의 내용은 바꿀 수 있습니다. 다만 처음 선언한 타입은 그대로 유지되어야 합니다.

fn main() {
    let mut tup1 = (0, 0.1, "hello");

    let mut x = tup1.0;
    let (_, mut y, _) = tup1;

    x = 1;
    y = 1.1;

    println!("{:?} {} {}", tup1, x, y);

    tup1.0 = 3;
}

실행 결과

(0, 0.1, "hello") 1 1.1

해시맵

해시맵은 키와 밸류를 묶어서 관리하는 자료형으로, 키에 대응하는 밸류를 빠르게 찾을 수 있는 장점이 있습니다. 특히 데이터를 인덱스로 관리하지 않는 경우에 유용합니다.

파이썬에서는 해시맵을 딕셔너리로 구현하고 있습니다. 다음 예제 코드에서는 songs 딕셔너리에 가수 이름과 대표 곡을 넣어 두었습니다. 그리고 딕셔너리에 특정 키나 밸류가 포함되어 있는지를 찾는 방법, 새로운 키를 넣거나 기존의 밸류를 업데이트하는 방법, 마지막으로 특정 원소를 삭제하는 방법 그리고 존재하지 않는 키를 참조할 때의 처리 방법을 다루고 있습니다.

songs = {
    "Toto": "Africa",
    "Post Malone": "Rockstar",
    "twenty one pilots": "Stressed Out",
}
print("----- Playlists -----")
if "Toto" in songs and "Africa" in songs.values():
    print("Toto's africa is the best song!")

songs["a-ha"] = "Take on Me"  # Insert
songs["Post Malone"] = "Happier"  # Update

for artist, title in songs.items():
    print(f"{artist} - {title}")
print("---------------------")

songs.pop("Post Malone")  # Delete
print(songs.get("Post Malone", "Post Malone is not in the playlist"))

실행 결과

----- Playlists -----
Toto's africa is the best song!
Toto - Africa
Post Malone - Happier
twenty one pilots - Stressed Out
a-ha - Take on Me
---------------------
Post Malone is not in the playlist

러스트에서는 해시맵을 HashMap 을 이용해 구현이 가능합니다. 아래 예제에서는 파이썬 코드와 동일하게 해시맵을 선언하고 가수 이름과 대표 곡을 저장했습니다. 그리고 특정 키나 밸류가 해시맵에 포함되어 있는지를 검사합니다. 새로운 키와 밸류 쌍을 추가하고, 수정하고, 삭제하는 방법, 그리고 존재하지 않는 키를 참조했을 때의 처리 방법을 소개합니다. 여기서 마지막에 unwrap_or(&...) 는 앞의 코드가 에러를 발생시켰을 때 처리하는 방법으로, 자세한 문법은 에러 처리 챕터에서 다루겠습니다.

use std::collections::HashMap;

fn main() {
    // Rust's HashMap does not keep the insertion order.
    let mut songs = HashMap::from([
        ("Toto", "Africa"),
        ("Post Malone", "Rockstar"),
        ("twenty one pilots", "Stressed Out"),
    ]);
    println!("----- Playlists -----");
    if songs.contains_key("Toto") && songs.values().any(|&val| val == "Africa") {
        println!("Toto's africa is the best song!");
    }

    songs.insert("a-ha", "Take on Me"); // Insert
    songs.entry("Post Malone").and_modify(|v| *v = "Happier"); // Update

    for (artist, title) in songs.iter() {
        println!("{} - {}", artist, title);
    }

    println!("---------------------");
    songs.remove("Post Malone"); // Delete
    println!(
        "{:?}",
        songs
            .get("Post Malone")
            .unwrap_or(&"Post Malone is not in the playlist")
    );
}

실행 결과

----- Playlists -----
Toto's africa is the best song!
Post Malone - Happier
Toto - Africa
twenty one pilots - Stressed Out
a-ha - Take on Me
---------------------
"Post Malone is not in the playlist"

여기서 파이썬과 러스트의 출력 순서가 다른데, 이는 파이썬이 3.6버전부터 원소의 삽입 순서를 보존하기 때문입니다. 만일 러스트에서도 삽입 순서를 보존하고 싶다면 HashMap 대신 indexmap 크레이트를 사용해야 합니다.

문자열

문자열 생성하기

문자열을 만드는 방법에는 두 가지가 존재합니다. 코드가 컴파일 될 때 스택 영역에 만들어지는 str과 런타임에 힙 영역에 메모리가 할당되는 String입니다.

str 타입은 문자열 리터럴로 불리며, 아래와 같이 선언할 수 있습니다. str 타입은 한 번 만들어지면 값을 변경하거나 길이를 바꿀 수 없습니다.

fn main() {
    let s = "hello";
    println!("{}", s);
}

일반적으로 문자열 혹은 스트링이라고 말하는 String 타입은 아래와 같이 여러 방법으로 선언이 가능합니다. 벡터와 마찬가지로 동적으로 값을 바꾸거나 길이를 바꿀 수 있습니다.

fn main() {
    // 비어 있는 스트링 만들기
    let mut s = String::new();

    // 스트링 리터럴로부터 스트링 만들기
    let data = "initial contents";
    let s = data.to_string();
    let s = "initial contents".to_string();

    // String::from()을 사용하여 스트링 만들기
    let s = String::from("initial contents");
}

정리하자면 다음과 같습니다.

  • 문자열 데이터의 소유권을 다뤄야 하는 경우 String을 사용
  • 문자열의 값만 필요한 경우 &str을 사용

문자열 슬라이스

&str 은 문자열의 일부분을 의미하기도 합니다. 따라서 &str을 문자열 슬라이스라고 부릅니다. String 타입으로 문자열을 선언하고, 해당 문자열로부터 문자열 슬라이스를 만들어 프린트해 보겠습니다.

fn main() {
    let greet = String::from("Hi, buzzi!");
    // let name = "buzzi!";
    let name = &greet[4..];
    println!("{}", name);
}

실행 결과

buzzi!

문자열 슬라이스를 사용할 때 주의해야 하는 점은, 러스트의 모든 문자열은 UTF-8로 인코딩되어 있다는 점입니다. 실제로 문자열 슬라이스의 인덱스는 문자 단위가 아닌 바이트 스트림의 바이트 단위 입니다. 아래 예제를 살펴봅시다.

fn main() {
    let greet = String::from("Hi😃 buzzi!");
    let name = &greet[4..];
    println!("{}", name);
}

실행 결과

thread 'main' panicked at 'byte index 4 is not a char boundary; it is inside '😃' (bytes 2..6) of `Hi😃 buzzi!`', src/main.rs:4:17

일반적인 알파벳 문자는 바이트 스트림에서 1바이트를 차지하지만, 유니코드로 만들어진 이모지의 경우는 4바이트를 차지하기 때문입니다. 바이트 4에 해당하는 인덱스가 이모지 중간에 위치하므로 정상적으로 문자열을 잘라낼 수 없게 됩니다. 따라서 반드시 스트링을 문자 단위로 슬라이스하고 싶은 경우라면 문자열을 벡터로 만들어줘야 합니다.

fn main() {
    let greet = String::from("Hi😃 buzzi!");
    let greet_chars: Vec<char> = greet.chars().collect();
    let name = &greet_chars[4..].iter().collect::<String>();
    println!("{:?}", name);
}