기본 강좌 연재에 들어가기에 앞서 문자열에 대한 설명을 먼저 다루고자 한다. 최신 개발 언어의 특징은 고전적인 컴파일러(C/C++)가 아닌 바로 실무에 사용할 수 있도록 필요한 라이브러리가 포함된 프레임워크 형태로 제공되고 메모리 관리 또한 GC(garbage collection) 형태가 많다. 개발자는 바로 실무에 유용한 함수를 사용(적용)하면 되는 것이다.

이러한 이유로 Rust를 학습할 때 C/C++ 언어의 개념이 없는 초급자는 특히 문자열을 이해하는 데 어려움을 겪는다. 포인터, 참조, Char와 String 구분이 대표적이다. C++에서 문자열을 다루는 간단한 예제를 살펴보고 Rust에서는 문자열을 어떻게 다루는지 비교해 보자. 이후에 Rust의 기본 문법부터 차근차근 정리할 것이므로 이번 포스팅은 건너뛰어도 문제가 없다.

C++에서 char, string
#include <iostream>
#include <cstring>

using namespace std;

int main()
{
    char arr[100] = {'H', 'e', 'l', 'l', 'o'};
    cout << sizeof(arr) << " : " << arr << endl;

    char arr1[] = "Hello";
    cout << sizeof(arr1) << " : " << arr1 << endl;

    const char *arr2 = "Hello";
    cout << sizeof(arr2) << " : " << arr2 << endl;

    char *arr3 = new char[100];
    strcpy_s(arr3, 100, "Hello");
    strcat_s(arr3, 100, " World");
    cout << strlen(arr3) << " : " << arr3 << endl;

    delete[] arr3;

    string str = "Hello";
    str = str + " World";
    cout << size(str) << " : " << str << endl;

    const char *strChar = str.c_str();
    cout << strlen(strChar) << " : " << strChar << endl;

    return 0;
}

C++에서 string은 기본 자료형이 아니라 클래스/객체이다. 대부분의 개발 언어가 원시적으로 기본 데이터 타입에는 문자열(String)은 없다. char의 연속일 뿐이다. 그래서 배열과 포인터로 이 연속을 문자열로 표현(View)하는 것이다. C++에서는 이것을 string 클래스로 제공한다.

Rust에 슬라이스(Slices)라는 용어가 있는데 이것은 컬렉션의 일부 연속 요소를 참조하는데 사용한다.1 Rust에서 &str은 (string)slices이며 C++의 char*와 유사하고 String은 C++의 string과 유사하지만 많은 차이점은 존재한다.

String은 힙(Heap)에 할당되고 변경가능(Mutable)하다. 반면에 &str은 좀더 복잡한데 기본적으로 하드코딩된 문자열처럼 변경이 불가하다. &str은 스택에 할당되기도 하고, 때론 힙 참조이며 코드에 포함되기도 한다.2

String vs &str

String holds a string in memory and owns the memory for it. &str is just a reference to another string but it doesn’t own the memory for it.3 Prefer &str as a function parameter or if you want a read-only view of a string; String when you want to own and mutate a string.4

String
  • String contains a string in memory and owns the memory for it.
  • Use String for returning strings created within a function or (usually) when storing strings in a struct or enum.
  • If you have a String you can pass a reference to it to convert it to &str.
&str
  • &str is just a reference to another string (slice) but does not own the memory for it.
  • Prefer &str in function arguments to accept string slices and make it clear the function will not mutate the string.
  • If you have a &str and want a new String you can clone it either by to_owned() or to_string() (they are effectively the same - use whichever makes your code clearer to read and consistent). These will copy the memory and make a new String.
// &str
let c: &str = "Hello World"; 

// String
let s: String = c.to_string();
let s: String = String::from("Hello World");

&str은 Rust 언어가 구현하는 원시 타입이지만 String은 표준 라이브러리에 구현되어 있다.

『String is an owned buffer of UTF-8 bytes allocated on the heap. Mutable Strings can be modified, growing their capacity as needed. &str is a fixed-capacity “view” into a String allocated elsewhere, commonly on the heap, in the case of slices dereferenced from Strings, or in static memory, in the case of string literals. &str is a primitive type implemented by the Rust language, while String is implemented in the standard library.5

Translate between String and &str

to_string() 메소드는 &strString로 변환하고, String에서 참조를 빌리면 &str로 자동 변환된다.

fn main() {
    let s = "Jane Doe".to_string();
    say_hello(&s);
}

fn say_hello(name: &str) {
    println!("Hello {}!", name);
}

위의 예제를 좀더 확장해 보면:

fn main() {
    let c: &str = "World"; // immutable
    // let s: &'static str = "World";    
    let s: String = c.to_string();
    let result = say_hello(&s);
    println!("{}", result);

    let mut my_str = "가나닭"; // to mutable
    println!("{}", my_str);
    my_str = &"마바삵";
    println!("{}", my_str);
}

fn say_hello(name: &str) -> String {
    let s: String = String::from("Hello");
    let c: char = 32 as char;
    s + &c.to_string() + &name.to_string()
}

String의 원 소스를 보면 아래와 같이 정의되어 있다.

#[derive(PartialOrd, Eq, Ord)]
#[cfg_attr(not(test), rustc_diagnostic_item = "String")]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct String {
    vec: Vec<u8>,
}

Rust는 소유권(ownership), 빌림/대여(borrowing), 슬라이스의 개념으로 프로그램의 메모리 안전성을 컴파일 타임에 보장한다. 다른 프로그램언어와 같이 메모리 사용에 대한 제어권은 주지만, 데이터의 소유자가 스코프 밖으로 벗어났을 때 소유자가 자동으로 데이터를 버리도록 하는 것이다.

이를 통해 직접 메모리 할당/해제를 하거나 가비지 컬렉터를 사용하지 않아도 메모리를 관리할 수 있다. 소유권의 규칙은 모든 값은 Owner라고 불리는 변수가 있고 한 번에 하나의 Owner만 존재할 수 있어서 Owner가 스코프 밖으로 벗어나면 값은 버려진다. 보다 자세한 설명은 이후 연재할 강좌에서 개별 주제로 다룰 예정이다.

Reference

  1. YONGJIN LAB, “[Rust] 슬라이스”
  2. Doug Milford, “Rust String vs str slices”, 『Often allocated on the stack, sometimes a heap reference, sometimes embeded in the code』
  3. dev.to, “Rust String vs &str”
  4. ameyalokare.com, “Rust: str vs String”
  5. prev.rust-lang.org, “Frequently Asked Questions, Strings”