KoreanFoodie's Study

C++ 기초 개념 10-4 : C++ 문자열의 모든 것 (string과 string_view) 본문

Tutorials/C++ : Beginner

C++ 기초 개념 10-4 : C++ 문자열의 모든 것 (string과 string_view)

GoldGiver 2022. 4. 18. 17:07

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

std::string

std::string 이란 무엇일까?
사실 std::string 은 basic_string이라는 클래스 템플릿의 인스턴스화 버전이다. basic_string definition 은 다음과 같다 :

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

Traits 는 주어진 CharT 문자들에 대해 기본적인 문자열 연산들을 정의해 놓은 클래스를 의미한다. 여기서, Traits를 바꾸면 정렬 순서를 바꿀 수 있는 등 여러가지 조작을 할 수 있다! 

다시 말해, basic_string 에 정의된 문자열 연산들은 사실 전부 Traits 의 기본적인 문자열 연산들을 가지고 정의되어 있다. 덕분에 문자열들을 어떻게 보관하는지에 대한 로직과 문자열들을 어떻게 연산하는지에 대한 로직을 분리시킬 수 있다. 전자는 basic_string 에서 해결하고, 후자는 Traits 에서 담당한다.

string 이외의 인스턴스화된 문자열들

 

이렇게 로직을 분리한 이유는 basic_string 의 사용자에게 더 자유를 부여하기 위해서이다. 예를 들어, string 에서 문자열 비교시 대소 문자 구분을 하지 않는 버전을 만든다고 가정해 보자. 그렇다면 Traits 에서 문자열을 비교하는 부분만 살짝 바꿔주면 된다. Traits 에는 <string> 에 정의된 std::char_traits 클래스의 인스턴스화 버전을 전달한다.

숫자들의 순위가 알파벳보다 낮은 문자열 클래스를 만든 예시 코드를 보자.

#include <cctype>
#include <iostream>
#include <string>

// char_traits 의 모든 함수들은 static 함수 입니다.
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;
  }
};

int main() {
  std::basic_string<char, my_char_traits> my_s1 = "1a";
  std::basic_string<char, my_char_traits> my_s2 = "a1";

  std::cout << "숫자의 우선순위가 더 낮은 문자열 : " << std::boolalpha
            << (my_s1 < my_s2) << std::endl;

  std::string s1 = "1a";
  std::string s2 = "a1";

  std::cout << "일반 문자열 : " << std::boolalpha << (s1 < s2) << std::endl;
}

 

 

짧은 문자열 최적화 (SSO)

메모리 할당은 많은 시간을 잡아먹는다!
basic_string은 짧은 길이 문자열의 경우 따로 문자 데이터를 위한 메모리를 할당하는 대신, 그냥 객체 자체에 저장해버린다. 이것을 Short String Optimization (SSO) 라고 한다.

다음의 코드를 보자.

#include <iostream>
#include <string>

// 이와 같이 new 를 전역 함수로 정의하면 모든 new 연산자를 오버로딩 해버린다.
// (어떤 클래스의 멤버 함수로 정의하면 해당 클래스의 new 만 오버로딩됨)
void* operator new(std::size_t count) {
  std::cout << count << " bytes 할당 " << std::endl;
  return malloc(count);
}

int main() {
  std::cout << "s1 생성 --- " << std::endl;
  std::string s1 = "this is a pretty long sentence!!!";
  std::cout << "s1 크기 : " << sizeof(s1) << std::endl;

  std::cout << "s2 생성 --- " << std::endl;
  std::string s2 = "short sentence";
  std::cout << "s2 크기 : " << sizeof(s2) << std::endl;
}

 

위 코드의 결과값은 다음과 같다.

s1 생성 --- 
34 bytes 할당 
s1 크기 : 32
s2 생성 --- 
s2 크기 : 32

즉, s1 의 경우 new 를 호출해 메모리를 할당했지만, s2 의 경우 32 바이트의 문자열 객체를 만들어 문자열을 바로 저장했다는 것을 알 수 있다!

 

 

리터럴 연산자(literal operator)

문자열 리터럴을 다음과 같이 정의해줄 수 있다.

auto str = "hello"s;

만약 s 를 붙이지 않으면, str 의 타입은 const char* 로 정의된다! 참고로 위의 리터럴 연산자를 사용하기 위해서는 std::literals 이라는 namespace 를 사용해 주어야 한다.

 

C++ 11 에 Raw String Literal 이라는 기능이 생겼다.

#include <iostream>
#include <string>

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

  std::cout << str;
}

 

 

만약 괄호를 넣고 싶다면 아래의 문법을 사용한다.

// Raw string 문법
R"/* delimiter */( /* 문자열 */ )/* delimiter */"

// delimiter 는 일치해야 한다! 아래 코드에서는 foo

#include <iostream>
#include <string>

int main() {
  std::string str = R"foo(
)"; <-- 무시됨
)foo";

  std::cout << str;
}

같은 느낌으로... R을 붙이는게 포인트! foot 대신에 어떤 단어가 와도 상관없다! 문법이 복잡하다고 느껴지면 그냥 "delimiter( 가 하나의 괄호라고 생각하면 편하다.

 

 

C++에서 한글 다루기

모든 문자들을 표현할 수 있도록 설계된 표준인 유니코드를 이용한다. 인코딩 방식은 다음과 같다 :

  • UTF-8 : 문자를 최소 1부터 4바이트로 표현 (문자마다 길이가 다름)
  • UTF-16 : 문자를 2 또는 4 바이트로 표현한다.
  • UTF-32 : 문자를 4 바이트로 표현한다.

 

예시 코드를 보자.

  //                         1234567890 123 4 567
  std::u32string u32_str = U"이건 UTF-32 문자열 입니다";
  std::cout << u32_str.size() << std::endl;
  
  // 결과값 : 17

UTF-32 는 각 글자를 각각 4바이트 씩 할당해서 저장하므로, 글자수인 17 이 나온다.

 

  //                   12 345678901 2 3456
  std::string str = u8"이건 UTF-8 문자열 입니다";
  std::cout << str.size() << std::endl;
  
  // 결과값 : 32

UTF-8 의 경우 한글은 3바이트, 나머지는 1바이트로 계산해 3*8 + 1*8 = 32 가 나왔다. 이는 총 32 개의 char 가 필요하다는 뜻인데, std::string 은 인코딩에는 관심이 없고 그냥 단순하게 char 의 나열로 이루어져 있다고 생각한다. 따라서 str.size() 를 하면 문자열의 실제 길이가 아니라 char 이 몇 개가 있는지 알려준다.

따라서 str[1] 을 호출하면 "건" 이 나오는 것이 아니라, "이" 에서 UTF-8 인코딩의 두 번째 바이트 (UTF-8 인코딩에서 불가능한 값) 이 나오게 된다. 따라서 UTF-8 인코딩 문자열을 제대로 읽으려면 str[i] 를 0b1111110000 등의 바이트 범위와 비교하여 읽어야 한다.

 

  //                         1234567890 123 4 567
  std::u16string u16_str = u"이건 UTF-16 문자열 입니다";
  std::cout << u16_str.size() << std::endl;
  
  // 결과값 : 17

UTF-16 인코딩은 최소 단위가 2바이트이다. 대부분의 문자들이 2바이트로 인코딩 된다! (알파벳, 한글, 한자 전부) 이모지나 상형문자는 4바이트로 인코딩된다.

UTF8-CPP 라는, C++ 에서 여러 방식으로 인코딩된 문자열을 쉽게 다룰 수 있게 도와주는 라이브러리도 있다!

 

 

string_view

만일 어떤 함수에다 문자열을 전달할 때, 읽기만 필요로 할 경우 보통 const std::string& 이나 const char* 로 받게 된다. 각각은 문제점이 있다. 예시 코드를 보자.

#include <iostream>
#include <string>

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;
}

위의 경우, contains_very 에서 const char* 을 인자로 넣어버려 std::string 객체가 생성되는 문제가 있다. 이를 해결하기 위해서는 const char* 을 인자로 받는 contains_very 함수를 하나 더 만들어 주어야 하는데...

 

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

위와 같은 문제는 string_view 가 나옴으로써 해결되었다!

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

string_view 는 문자열을 참조해서 읽기만 하는 클래스로, string_view 가 현재 보고 있는 문자열이 소멸되면 Undefined behavior 가 발생한다.

하지만 소유하지 않는 특성 덕분에 string_view 객체 생성 시에 메모리할당이 불필요하다. 읽고 있는 문자열의 시작 주소값만 복사하면 되기 때문이다!

#include <iostream>
#include <string>

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

int main() {
  std::cout << "string -----" << std::endl;
  std::string s = "sometimes string is very slow";
  std::cout << "--------------------" << std::endl;
  std::cout << s.substr(0, 20) << std::endl << std::endl;

  std::cout << "string_view -----" << std::endl;
  std::string_view sv = s;
  std::cout << "--------------------" << std::endl;
  std::cout << sv.substr(0, 20) << std::endl;
}

위의 코드를 돌리면, s 는 메모리를 할당하지만, sv 는 메모리를 할당하지 않는다는 것을 확인할 수 있다.

 

다만 string_view 을 사용하기 위해서는, 참조하는 원본 string 이 삭제되지 않아야 한다.

#include <iostream>
#include <string>

std::string_view return_sv() {
  std::string s = "this is a string";
  std::string_view sv = s;

  return sv;
}

int main() {
  std::string_view sv = return_sv();  // <- sv 가 가리키는 s 는 이미 소멸됨!

  // Undefined behavior!!!!
  std::cout << sv << std::endl;
}

 

Comments