문자열의 UTF-8 인코딩

문자열의 UTF-8 인코딩

Rust에서 문자열을 표현하기 위해서는, 일반적으로 문자열 슬라이스(string slice)인 &str 타입과 표준 라이브러리에 있는 String 타입을 사용한다. (참고로, Rust 표준 라이브러리에는 이외에도 OsString, OsStr, CString, CStr 등과 같은 문자열 타입이 있다.) &str 타입과 String 타입의 문자열은 모두 UTF-8 인코딩을 사용하고 있다.

String 타입은 소유권을 가지고 있으며(owned), 문자열을 늘릴 수 있고(growable), 문자열의 내용을 변경할(mutable) 수 있다. 반면, string slice인 str 타입은 다른 저장소에 있는 문자열을 borrow 하는 (borrowed) 타입이다.

문자열 생성 및 초기화

문자열을 생성하고 초기화하는 방법으로 아래 (1) ~ (3) 의 방법을 많이 사용한다. (1) String::new() 함수는 빈 문자열을 갖는 String 타입을 만들게 되고, 초기화 이후 (push_str 등을 사용하여) 문자열을 추가할 수 있다. (2) String::from() 함수는 초기화할 문자열을 지정하면서 String 타입을 생성하게 된다. (3) string slice에 to_string() 메서드를 호출하게 되면, string slice의 데이타로부터 String 타입을 생성하게 된다.

fn main() {
    // (1) String::new()
    let mut s1 = String::new();
    s1.push_str("initial");
    
    // (2) String::from()
    let s2 = String::from("initial");

    // (3) &str.to_string()
    let s: &str = "hello";
    let s3: String = s.to_string();
    println!("{}", s3);
    
    // (4) {Display}.to_string();
    let n: i32 = 123;
    let s4: String = n.to_string();
    println!("{}", s4);
}

(3)의 to_string() 메서드는 실제 &str 타입 뿐만 아니라, Display 트레이트(trait)를 갖는 타입에 모두 사용할 수 있는데, 예를 들어 숫자 i32 타입에도 사용할 수 있다. (4)는 이러한 예를 표현한 것으로 s4는 String 타입의 "123"을 갖게 된다.

문자열 병합(concatenation)

문자열 병합은 여러가지 방법으로 할 수 있는데, 기본적으로 병합된 문자열을 저장할 공간이 확보되어 한다. 아래 방법(1)의 경우, 변수 s1은 String 타입으로 여기에서 &str 타입의 문자열을 추가하는 방법이다.

fn main() {
    // (1) s1.push_str()
    let mut s1: String = String::from("hello ");
    let s2: &str = "world";
    s1.push_str(s2);
    println!("{}", s1);

    // (2) + operator
    let a: String = String::from("hello ");
    let b: String = String::from("world");
    let c = a + &b;
    println!("{}", c);

    // (3) format!() macro
    let sa: String = String::from("hello");
    let sb: String = String::from("world");
    let sc: String = format!("{} {}", sa, sb);
    println!("{}", sc);
}

(2)의 경우는 + 연산자를 사용하는 방법으로, + 연산자의 앞에 있는 변수는 & 가 붙지 않고 뒤에 있는 변수는 & 이 붙도록 되어 있다. 이 방법은 + 앞의 변수 a의 소유권(ownership)을 가져온 후 그 메모리의 뒤에 &b 의 문자열을 추가하고 이를 다시 변수 c에 할당하는 것이다. + 연산 뒷 문장에서는 변수 a의 경우 그 소유권이 이동(Move)하였기 때문에 변수 a를 사용할 수 없지만, 변수 b는 borrow된 것이므로 사용할 수 있다.

(3)의 format!() 매크로의 경우는 전달되는 모든 파라미터가 레퍼런스로 borrow 되면서 병합하게 된다. format!() 매크로는 println!() 매크로와 비슷하게 문자열들을 연결시키는데 콘솔에 출력하는 대신 결과 문자열을 리턴한다. format! 매크로의 파라미터들은 borrow된 타입으로 format! 이후의 문장에서 사용될 수 있다.

문자열의 문자처리

Rust에서는 (다른 프로그래밍 언어와 달리) 문자열 인덱스를 사용하지 않고, 대신 문자열 슬라이스를 사용한다. 문자열 인덱스는 일반적으로 알파벳이나 바이트 단위의 처리에는 적합하지만, 알파벳 이외의 문자들은 UTF-8 으로 인코딩하면 2 ~ 4 바이트를 차지하므로, UFT-8 인코딩을 기본으로 사용하는 Rust에서는 인덱스보다 인덱스의 범위를 지정할 수 있는 슬라이스를 기본적으로 사용하게 되었다.

아래 예제와 같이 문자열 변수 s에 대해 s[0] 와 같은 인덱스를 사용하면, Rust는 인덱스를 지원하지 않으므로 바로 컴파일 에러를 발생시킨다. 하지만, s[0..3]와 같이 슬라이스를 사용하면, 첫번째 한글 문자인 "대"를 가져오게 된다.

fn main() {
    let s = "대한민국";

    // 문자열 인덱스 : 에러 발생!
    let ch = &s[0];

    // 문자열 슬라이스 : OK
    let ch = &s[0..3];  // 대
    println!("{}", ch);
}

참고로 유니코드에서 한글은 2바이트를 차지하지만, UFT-8에서 3바이트를 차지하기 때문에 s[0..2]가 아닌 s[0..3]으로 표시한 것이다. 만약 s[0..2]로 코딩하면, 아래와 같은 런타임 에러가 발생할 것이다.

    thread 'main' panicked at 'byte index 2 is not a char boundary; 
    it is inside '대' (bytes 0..3) of `대한민국`'

문자열에 있는 문자를 처리하는 일반적인 방법은 char 단위로 처리하는 것이다. 즉, 하나의 문자가 UTF-8 인코딩에서 1 ~ 4개의 바이트가 될 수 있기 때문에 바이트 단위보다는 char 단위로 처리하는 것이 편리하다. 아래 예제에서 보면, chars() 메서드는 문자열(&str 혹은 String 타입)에서 문자들의 집합을 리턴하는데, 이를 for 루프에서 각 문자별로 처리하고 있다. 마찬가지 방법으로, 만약 바이트 단위로 처리한려면, bytes() 메서드를 호출하면 된다.

fn main() {
    let s = "대한민국";

    // char 단위 처리
    for c in s.chars() {
        println!("{}", c);
    }

    // byte 단위 처리
    for b in s.bytes() {
        println!("{:X?}", b);
    }
}

This site is not affiliated with or endorsed by the Rust Foundation or Rust Project.