String

Date:     Updated:

카테고리:

태그:

모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.


< Char T >

template <class CharT, class Traits = std::char_traits<CharT>,
          class Allocator = std::allocator<CharT> >
class basic_string;

std::string 은 사실 basic_string 이라는 클래스 템플릿의 인스턴스화 버전이다. basic_string< char T >타입의 객체들을 메모리에 연속적으로 저장하고, 여러가지 문자열 연산들을 지원해주는 클래스이다.

타입 정의 비고
std::string std::basic_string < char >  
std::wstring std::baisc_string< wchar_t > wchar_t의 크기는 시스템마다 다름.
윈도우 : 2바이트, 유닉스 : 4바이트
std::u8string std::basic_string< char8_t > c++ 20에 새로 추가되었음.
UTF-8 문자열 보관. 1바이트
std::u16string std::basic_string< char16_t > UTF-16 문자열 보관. 2바이트
std::u32string std::basic_string< char32_t > UTF-32 문자열 보관. 4바이트

Traits

Traits는 문자열의 연산들을 정의해 놓은 클래스이다. 예를 들어 주어진 문자열의 대소 비교를 어떻게 할 것인지, 문자열의 길이를 어떻게 잴 것인지 등등을 말한다.

따라서 문자열을 어떻게 보관하는지에 대한 로직과, 문자열을 어떻게 연산하는지 에 대한 로직을 분리시킬 수 있다. 전자는 basic_string이 담당하고, 후자는 Traits에서 담당한다.

예를 들어 char이 기본 타입인 문자열에서, 비교시 대소 문자 구분을 하지 않는 버전을 만들고 싶다고 해보자. 그렇다면 그냥 Trait에서 문자열 비교 부분만 살짝 바꿔주면 된다.

Traits에는 < string >에 정의된 std::char_traits 클래스의 인스턴스화 버전을 전달한다. 아래 예시에서 숫자들의 순위가 알파벳보다 낮은 문자열을 만들어보자.

struct my_char_traits : public std::char_traits<char> {
  static int get_real_rank(char c) {
    // 숫자면 순위를 엄청 떨어트린다.
    if (isdigit(c)) {
      return c + 256;
    }
    return c;
  }

  static bool lt(char c1, char c2) {
    return get_real_rank(c1) < get_real_rank(c2);
  }

  static int compare(const char* s1, const char* s2, size_t n) {
    while (n-- != 0) {
      if (get_real_rank(*s1) < get_real_rank(*s2)) {
        return -1;
      }
      if (get_real_rank(*s1) > get_real_rank(*s2)) {
        return 1;
      }
      ++s1;
      ++s2;
    }
    return 0;
  }
};

std::basic_string<char, my_char_traits> my_s1 = "1a";
std::basic_string<char, my_char_traits> my_s2 = "a1";

Traits에는 char_traits에서 제공하는 모든 멤버 함수들이 구현된 클래스가 전달되어야 한다. 가장 간편한 방법은 그냥 char_traits를 상속 받은 후, 필요한 부분들만 새로 구현하는 것이다.

참고로 char_traits에 정의된 모든 함수들은 static이다. 그 이유는 간단한데, Traits는 문자열들 간에 간단한 연산을 제공해주는 클래스이므로 굳이 데이터를 저장할 필요가 없기 때문이다. (이를 Stateless 하다고 한다.)

일반적인 char_traits<char>에서 우리가 바꿔줘야 할 부분은 대소 비교하는 부분 뿐이다. 따라서 위와 같이 문자들 간의 크기를 비교하는 lt 함수와, 길이가 n인 문자열의 크기를 비교하는 compare함수를 재정의해야 한다.

짧은 문자열 최적화 (SSO)

baisc_string이 저장하는 문자열의 길이는 천차 만별이다. 때론 한 두 문자 정도의 짧은 문자열을 저장할 때도 있고, 수십만 바이트의 거대한 문자열을 저장할 때도 있다. 문제는 거대한 문자열은 매우 드물게 저장되는데 반해, 길이가 짧은 문자열들은 굉장히 자주 생성되고 소멸된다는 것이다. 이전에도 이야기 했지만 메모리를 할당하는 작업은 꽤나 비싼 작업이다. 따라서 basic_string의 제작자들은 짧은 길이 문자열의 경우 문자 데이터를 위한 메모리를 따로 할당하는 대신에 그냥 객체에 저장해버린다. 이를 짧은 문자열 최적화(SSO - short string optimization) 이라고 부른다.

다만 SSO를 사용하는 경우 단순 문자열보다 메모리를 크게 먹는다. 정말 단순하게 문자열 라이브러리를 구현했다면 문자열 길이를 저장할 변수 하나, 할당한 메모리 공간의 크기를 저장할 변수 하나, 메모리 포인터 하나 해서 총 12 바이트로 만들 수 있을 것이다. 하지만 라이브러리 제작자들은 메모리 사용량을 조금 희생하는 대신 성능 향상을 꾀했다.

문자열 리터럴 정의

auto str = "hello";

c++ 에서는 C와 마찬가지로 위 코드의 strconst char *로 정의된다. string이 아닌 char * 타입은 문자열 길이에 대한 정보를 잃는 등 귀찮은 상황이 발생하게 된다. 따라서 string 타입을 꼭 명시해줬어야 했는데 c++ 14 에 이 문제를 깜찍하게 해결하는 방법이 나왔다.

auto str = "hello"s;

위와 같이 " " 뒤에 s를 붙여주면 autostring으로 추론된다. 참고로 이 리터럴 연산자는

std::string operator"" s(const char *str, std::size_t len);

위 처럼 정의되는데, "hello"s 는 컴파일 과정에서 operator""s("hello", 5);로 변환된다.

이 외에도 문자열 리터럴을 정의하는 방법들이 더 존재한다. 이 중 유용한 기능으로 Raw string literal 이 있다.

int main() {
  std::string str = R"(asdfasdf
이 안에는
어떤 것들이 와도
// 이런것도 되고
#define hasldfjalskdfj
\n\n <--- Escape 안해도 됨
)";

  std::cout << str;
}

[!result] asdfasdf 이 안에는 어떤 것들이 와도 // 이런것도 되고 #define hasldfjalskdfj \n\n <— Escape 안해도 됨

c++ 에서 한글 다루기

처음 컴퓨터가 만들어졌을 때, 대부분 영미권 국가에서 사용했기 때문에 문자를 표현하는데 1바이트(255개) 로도 충분했다. 하지만 점차 전세계적으로 사용이 확대되면서 세계 각국에 문자를 나타내는데 한계가 느껴졌다. 이에 전세계 모든 문자들을 컴퓨터로 표현할 수 있도록 설계된 표준이 바로 유니코드(Unicode) 이다. 유니코드는 모든 문자들에 고유의 값을 부여한다.

예를 들어 한글의 0xAC00의 값을 부여 받았고, 그 다음에 오는 문자가 으로 0xAC01이다. 참고로 기존에 사용하던 아스키 테이블과의 호환을 위해 0부터 0x7F 까지는 동일하다. 즉, 영어 알파벳 A의 경우 그대로 0x41 이다.

현재 유니코드에 등록되어 있는 문자들의 갯수는 대략 14만 개(2바이트 초과) 정도 이므로, 문자 하나를 한 개의 자료형에 보관하기 위해 최소 int를 사용해야 한다. 하지만 모든 문자들을 4 바이트로 지정해서 표현하는 것은 매우 비효율적이다. 예를 들어 전체 텍스트가 전부 영어라면, 문자 당 1바이트만 사용해도 충분하기 때문이다.

그래서 등장한 것이 바로 인코딩(Encoding) 방식이다. 문자를 표현하기 위해 동일하게 4 바이트 씩을 사용하는 것이 아니라, 인코딩 방식에 따라 어떤 문자는 1 바이트, 어떤 문자는 2 바이트 등등의 길이로 저장하는 방식이다. 유니코드에서는 아래와 같이 3 가지 형식의 인코딩 방식을 지원한다.

  • UTF-8 : 문자를 최소 1 부터 최대 4 바이트로 표현 (즉, 문자마다 길이가 다르다)
  • UTF-16 : 문자를 2 혹은 4 바이트로 표현
  • UTF-32 : 문자를 4 바이트로 표현
std::u32string u32_str = U"이건 UTF-32 문자열 입니다";

u32string은 c++에서 UTF-32 로 인코딩 된 문자열을 보관하는 타입이고, U" "는 해당 문자열 리터럴을 UTF-32로 인코딩 하라는 의미이다. UTF-32의 경우 모든 문자들을 4 바이트로 할당하기 때문에 다루기가 매우 편하다.

std::string str = u8"이건 UTF-8 문자열 입니다";

str13

반면 UTF-8로 인코딩 된 위 문자열의 경우 size()를 찍어보면 32 바이트가 나온다. 문자열의 길이는 16이지만, 한글(3 바이트) x 8 + 영어,공백,-(1 바이트) x 8, 해서 총 3x8 + 1x8 = 32 바이트가 나온 것이다.

문제는 string 단에서 각각의 문자를 구분하지 못하기 때문에 불편함이 이만 저만이 아니라는 점이다. 예를 들어 아래 코드를 실행하면 두 번째 문자 “건” 이 나올 것 같지만 실제로는 이상한 결과가 나온다.

std::cout << str[1];

그렇다고 c++에서 UTF-8을 아예 분석할 수 없다는 것은 아니다. 불편하지만 아래 처럼 하나씩 차례대로 읽어 나갈 수 있다.

  std::string str = u8"이건 UTF-8 문자열 입니다";
  size_t i = 0;
  size_t len = 0;

  while (i < str.size()) {
    int char_size = 0;

    if ((str[i] & 0b11111000) == 0b11110000) {
      char_size = 4;
    } else if ((str[i] & 0b11110000) == 0b11100000) {
      char_size = 3;
    } else if ((str[i] & 0b11100000) == 0b11000000) {
      char_size = 2;
    } else if ((str[i] & 0b10000000) == 0b00000000) {
      char_size = 1;
    } else {
      std::cout << "이상한 문자 발견!" << std::endl;
      char_size = 1;
    }

    std::cout << str.substr(i, char_size) << std::endl;

    i += char_size;
    len++;
  }

위에 있는 UTF-8 인코딩 방식을 살펴보면, 4 바이트로 인코딩되는 문자들은 첫 번째 바이트가 11110xxx 꼴이다. 따라서 11111000AND 연산을 통해 해당 바이트에 해당하는 문자를 골라낼 수 있다.

UTF-16 Encoding

UTF-16으로 인코딩 된 문자열을 저장하는 u16string 클래스는 2 바이트 짜리 char16_t 원소로 이루어져 있다. UTF-16 인코딩 방식에서는 알파벳, 한글, 한자 등 대부분의 문자들이 2 바이트로 인코딩 된다. 물론 이모지나 상형문자와 같은 유니코드 상 높은 번호로 매핑되어 있는 특수한 문자들은 4 바이트로 인코딩 된다.

따라서 만약 일반적인 문자들만 수록되어 있는 텍스트를 다룬다면, u16string을 사용하는 것이 매우매우 좋다. 거의 대부분의 문자들이 2 바이트로 인코딩 될 것이므로, 모든 문자들이 1개의 원소 만큼을 사용할 것이다.

String-View

만약 어떤 함수에 문자열을 전달할 때, 문자열 읽기만 필요로 한다면 보통 const std::string& 이나 const char * 형태로 받을 것이다.하지만 각각의 방식은 문제가 있다.

void* operator new(std::size_t count) {
  std::cout << count << " bytes 할당 " << std::endl;
  return malloc(count);
}

// 문자열에 "very" 라는 단어가 있으면 true 를 리턴함
bool contains_very(const std::string& str) {
  return str.find("very") != std::string::npos;
}

int main() {
  // 암묵적으로 std::string 객체가 불필요하게 생성된다.
  std::cout << std::boolalpha << contains_very("c++ string is very easy to use")
            << std::endl;

  std::cout << contains_very("c++ string is not easy to use") << std::endl;
}

[!result] 31 bytes 할당 true 30 bytes 할당 false

contains_very 함수에 const char* 형태의 문자열 리터럴을 전달한다면, 인자는 string만 받을 수 있기 때문에 암묵적으로 string 객체가 생성되게 된다. 따라서 불필요하게 메모리 할당이 발생한다.

그렇다고 const char* 타입의 인자를 받도록 바꾸면 아래와 같은 두 가지 문제가 발생한다.

  • string을 함수에 직접 전달할 수 없고, c_str 함수를 통해 const char* 주소값을 뽑아내야 한다
  • const char*로 변환하는 과정에서 문자열의 길이에 대한 정보를 잃게 된다. 만약 함수 내부에서 문자열 길이에 대한 정보가 필요하면 매 번 다시 계산해야 한다.

이러한 연유로, contains_very 함수를 합리적으로 만들기 위해 const string& 버전 하나, const char* 버전 하나를 각각 오버로딩 해주어야 한다.

위와 같은 문제는 c++ 17에 도입된 string_view로 해결이 가능하다.

소유하지 않고 읽기만 한다!!

bool contains_very(std::string_view str) {
  return str.find("very") != std::string_view::npos;
}

string_view는 이름 그대로 문자열을 소유하지 않고 읽기만 한다. 즉, 어딘가 존재하는 문자열을 참조해서 읽기만 하는 것이다.

[!danger] string_view 가 현재 보고 있는 문자열이 소멸된다면 정의되지 않은 작업(Undefined Behavior)이 발생한다. 따라서 string_view를 사용할 때에는 현재 읽고 있는 문자열이 소멸되지 않은 상태인지 확인하는 것이 좋다.

string_view 객체는 읽으려는 문자열의 시작 주소만 복사하면 되기 때문에 추가적인 메모리 할당이 발생하지 않는다. 또한 string_view에서 제공하는 연산들은 당연하게도 findsubstr과 같은 원본 문자열을 수정하지 않는 연산들 이다.



맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기