ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • C++ string의 기초 완벽 이해
    Codings 2024. 2. 19. 20:10

     

    안녕하세요?

     

    오늘은 C++의 string에 대해 공부해 보겠습니다.

     

    ** 목 차 **


    1. string 소개

    C++을 처음 접하는 프로그래밍 초보자이든, C나 Python에서 넘어온 개발자이든 모두 '문자열'이 프로그래밍 언어에서 얼마나 중요한지 이미 알고 있을겁니다.

     

    C++에서는 문자열을 std::string 라이브러리(이 글에서는 string으로 축약해서 부르겠습니다)로 다룹니다.

     

    이 글에서는 좀 더 기초적인 std::string 라이브러리를 다뤄볼까합니다.

     

    그러면 왜 string이 필요할까요?

     

    string 없이 우리가 문자열을 어떻게 표현할 수 있을까요?

    const char *a = "abcdfe";
    char b[] = "123345";

     

    위와 같이 하는 것도 불가능한 것은 아닙니다.

     

    결국 우리는 C 언어에서 기본적으로 이렇게 했었으니까요.

     

    하지만 이 방법은 그리 편리하지 않은데요.

     

    예를 들어, 문자열의 길이를 알고 싶거나, 부분 문자열을 찾거나, 문자열을 추가하고 싶을 때 말이죠!

     

    string을 사용하면 세상이 훨씬 간단해집니다.

    string s = "aabbcc";
    s.size(); // 6을 반환
    s.find("bb"); // 2를 반환
    s += "123"; // s = aabbcc123

     

    여기서 잠깐, std::string을 사용할 때는 <string> 라이브러리를 포함시켜야 하는 건 당연한거겠죠.

     

    또, string은 표준 라이브러리이며, 일반적으로 std::string을 사용하여 조작하는데요, 간결함을 위해, 이 글에서는 using std::string;을 선언했다고 코드를 써 가겠습니다.

     

    코드의 맨 위에 아래 코드가 있다고 가정하고 시작하는 겁니다.

    #include <string>
    using std::string;

     

    이제 본격적으로 string에 대해 알아보겠습니다.


    2. string의 선언과 초기화

    string을 선언하는 방법을 살펴보겠습니다:

    string s1; // 기본 초기화, 빈 문자열
    string s2 = s1; // s2는 s1의 복사본
    string s3 = "hello world"; // s3는 문자열 값의 복사본(먼저 string으로 변환됨)
    string s4("hello world"); // s4는 주어진 문자열로 초기화됨

     

    이제 조금은 어려운 얘긴데,

    • 직접 초기화(direct initialization)
    • 복사 초기화(copy initialization)
    • 이동 초기화(move initialization)
    • 복사 할당(copy assignment)
    • 이동 할당(move assignment)

    에 대해 알아 보겠습니다.

    아래 설명이 조금 복잡할 수 있으니, 다음 예제 s5~s9 방법으로 string을 생성하는 것만 기억하면 됩니다.

    string s5("abc"); // 직접 초기화
    string s6 = "abc"; // 복사 할당
    string s7 = string("abc"); // 복사 할당
    string s8(s7); // 복사 초기화
    string s9 = s8; // 복사 초기화
    string s10(std::move(s9)); // 이동 초기화
    string s11 = std::move(s10); // 이동 할당

     

    s5는 매우 직관적인데요.

     

    프로그래머가 string에 어떤 값을 초기화할지 바로 알려주는 겁니다.

     

    s6 또한 매우 흔한 선언 방법이며, 사실상 s6과 s7은 완전히 동일합니다.

     

    s6 예제에서 우측 값은 실제로 암시적 형변환(implicit)을 거치게 되며, 형변환된 후에는 s7과 같아집니다.

     

    s6 또는 s7 예제는 복사 변환을 의미합니다.

     

    즉, 하나의 string 객체(우측 값의 string)를 사용하여 새로운 string 객체(좌측 값의 string)를 생성하는 것이죠.

     

    이는 string을 두 번 생성해야 한다는 것을 의미합니다.

     

    이런 방식으로 string을 선언하는 것은 원칙적으로 매우 비효율적이지만, 다행히도 요즘 컴파일러는 이 부분과 관련하여 최적화를 도와줍니다.

     

    실제로 컴파일된 후에는 차이가 없을 수도 있지만, 컴파일러의 최적화 도움 없이 원칙적으로 이러한 초기화의 차이로 인해서 최적화 부분에서 차이가 발생할 수 있다는 걸 이해할 필요는 있습니다.

     

    s8과 s9는 복사를 수행하지만, 이 문법을 사용할 때는 문자열 복사가 필요하다는 것을 명확히 알고 있기 때문에 문제가 되지 않습니다.

    s10은 간단히 말해서 s9의 string 내부 데이터를 '넘겨주는' 것입니다.

     

    이때 s9는 더 이상 사용할 수 없고, s10은 s9의 데이터를 직접 가져오기 때문에 초기화가 더 효율적입니다.

     

    s11의 개념은 s10과 같습니다.

     

    여기에는 std::move 및 우측 값(rvalue) 등의 개념이 포함되어 있어 조금은 어려운데요.

     

    이런게 있다고 이해하시고 일단 지나가시기 바랍니다.

     

    그리고 string을 초기화하는 방법은 string의 생성자 목록을 참조할 수 있지만, 대부분의 생성 방법은 좀 더 고급이므로, 우리는 위에서 언급한 몇 가지 string 초기화 방법을 알아 두는게 좋습니다.

     

    만약 당신이 std::vector 사용법을 알고 있다면, vector의 생성자 방법도 string에 적용됩니다.

     

    마지막으로 특별한 초기화 방법을 소개합니다:

    using namespace std::literals;
    string s3_2 = "hello world"s; // s3의 또 다른 표현 방식으로, ""s는 오른쪽 값을 string으로 직접 만듭니다.

     

    ""s 연산자를 통해, 우리는 문자열 리터럴(character string literals)을 string으로 직접 선언할 수 있습니다.

     

    하지만 using namespace std::literals;를 포함시켜야 하죠.


    3. string의 기본 연산 작업

    다음은 string에서 수행할 수 있는 연산 작업으로, 가장 일반적인 작업을 나열했습니다:

    os << s // s 출력   
    // 여기서 os는 출력 스트림(output stream)
    is >> s // s 입력
    // is는 입력 스트림(input stream)
    
    s.empty() // s가 비어 있는지 확인
    s.size() // s의 현재 길이
    s[n] // s의 n번째 요소 직접 가져오기
    s1 + s2 // s1에 s2를 더하여 새 문자열 얻기
    s1.append(s2) // s2를 s1 뒤에 추가
    s1 = s2 // s2 복사
    s1 != s2 // s1과 s2가 같은지 비교
    <, <=, ==, >=, > // 사전 순으로 크기 비교

    3.1 string의 입력과 출력

    간단한 프로그램을 작성하여 입력과 출력을 수행할 수 있습니다:

    #include <iostream>
    #include <string>
    int main() {
        std::string input;
        while(std::cin >> input) { // 데이터를 계속 읽어들이다가 EOF(파일 종료 기호)를 만나면 중단
            std::cout << input << std::endl; // 방금 받은 input 출력
        }
        return 0;
    }

     

    아래 코드처럼 한 줄씩 읽어들일 수도 있습니다:

    #include <iostream>
    #include <string>
    int main() {
        std::string line;
        while(std::getline(std::cin, line)) { // 데이터를 계속 읽어들이다가 \n으로 줄을 나누고 EOF(파일 종료 기호)를 만나면 중단
            std::cout << line << std::endl; // 방금 받은 input 출력
        }
        return 0;
    }

     

    물론 여기서는 ostream(std::cin, std::cout)일 필요는 없습니다.

     

    스트림이라면 기본적으로 사용할 수 있으며, 심지어 사용자 정의 C++ 객체의 입력과 출력을 위해 >>, << 연산자를 커스터마이징할 수도 있습니다.

    3.2 empty()와 size()

    empty()와 size()는 string을 확인하는 데 자주 사용되는 함수입니다.

    string s = "";
    if(s.empty()) {
        std::cout << "it's empty!";
    }
    
    s = "12345678910";
    if(s.size() > 5) {
        std::cout << "more than 5!";
    }

     

    예를 들어, 위의 getline 예제에서 빈 줄을 건너뛰고 싶다면 다음과 같이 작성할 수 있습니다:

        while(std::getline(std::cin, line)) {
            if(!line.empty()) { // 빈 문자열이 아닌지 확인
                std::cout << line << std::endl;
            }
        }

    !s.empty()는 s.size() > 0과 같은 의미이지만, 빈 문자열이 아님을 나타내는 데 !s.empty()를 사용하는 것이 더 간결한 표현 방식입니다.

     

    또한 size()의 반환 타입은 string::size_type이며, 실제 타입은 표준 라이브러리의 구현에 따라 달라집니다.

     

    일반적으로 size_t(음수가 아닌 정수)입니다.

     

    따라서 s.size()의 값은 int가 아닙니다!

     

    따라서 string을 순회할 때 다음과 같이 작성하는 것은 잘못되었습니다:

    // 잘못됨!
    for(int i = 0; i < s.size(); ++i) {
        std::cout << s[i];
    }

     

    올바른 방법은 다음과 같습니다:

    // 올바름
    for(std::string::size_type i = 0; i < s.size(); ++i) {
        std::cout << s[i];
    }

     

    size_t를 사용할 수도 있습니다.

     

    또는 귀찮다면 auto를 직접 사용하면 편합니다.

     

    이 경우에 int를 사용해도 되지만, int와 size_t(s.size())는 비교할 수 있기 때문입니다.

     

    그러나 s.size() < n을 비교하고 n이 int 음수일 때 오류가 발생할 수 있습니다.

     

    이 경우 n은 size_t로 변환되어 매우 큰 양수가 되므로 항상 true가 됩니다.

    3.3 문자열 접근

    string 내부의 문자에 어떻게 접근할까요?

     

    가장 간단한 문법은 s[]와 s.at() 두 가지입니다.

    string s("0123456789");
    
    s[2] = 'a'; // s = "01a3456789"
    std::cout << s[9]; // 9
    
    s.at(3) = '6'; // "01a6456789"
    std::cout << s.at(3); // 6

     

    위 코드에서 []at()의 문법과 사용효과가 비슷해 보이죠?

     

    실제로 차이점은 경계 검사를 수행하는지 여부입니다:

    std::cout << s[100]; // 의도적으로 경계를 벗어나 접근할 경우
    // 정의되지 않은 행동, 임의의 코드일 수도 있고, 세그먼테이션 폴트(Segmentation Fault)가 발생할 수도 있음
    
    std::cout << s.at(100); // 의도적으로 경계를 벗어나 접근할 경우
    // 'std::out_of_range' 인스턴스를 던진 후 종료됨
    //   what():  basic_string::at: __n (which is 100) >= this->size() (which is 10)
    // 중단됨

     

    at()는 경계 검사를 수행하여 코드에 문제가 있음을 명확히 알려줍니다.

     

    이 경우 try-catch 구문과 함께 오류 처리를 수행할 수 있습니다.

     

    반면, []를 사용하여 경계를 벗어난 접근을 하면 정의되지 않은 행동이 발생하며, 대부분의 경우 세그먼테이션 폴트가 발생합니다.

    그렇다고 []가 나쁜 것은 아닙니다.

     

    계 검사는 성능상의 비용이 들기 때문에(검사를 추가로 수행하기 때문에) []는 더 직관적이고 효율적입니다.

     

    다만 개발자는 접근하는 경계를 신중하게 처리해야 합니다.

     

    다음과 같이 검사를 수행할 수도 있습니다:

    string s("abcd");
    size_t index = /* 임의의 숫자 */;
    if(index >= 0 && index < s.size()) {
        std::cout << s[index];
    }

     

    또한 s.front()s.back() 두 함수도 자주 사용됩니다.

     

    이름에서 알 수 있듯이, 가장 앞과 뒤에 접근합니다.

    string s("abc");
    std::cout << s.front(); // a
    std::cout << s.back(); // c

     

    물론 s[0]s[s.size() - 1]을 사용할 수도 있지만, 그렇게 직관적이지 않고 보기에 좋지 않습니다.

    3.4 string의 결합

    3.4.1 string끼리 결합

    두 개의 문자열을 결합하여 "abc"와 "defg"를 "abcdefg"로 만듭니다.

     

    간단한 방법은 + 연산자를 사용하는 것이며, 예를 들어 s1 + s2입니다.

     

    다른 방법으로는 s1.append(s2)가 있습니다.

     

    예시를 살펴보겠습니다:

    string s1("aaa");
    string s2("bbb");
    
    string s3 = s1 + s2; // s3 = "aaabbb"
    
    s1 = s1 + s2; // case 1 비효율적
    s1 += s2; // case 2 효율적
    s1.append(s2); // case 3 효율적

     

    case 1에서 s1 + s2 연산은 두 개의 string을 새로운 string으로 만들고 s1에 복사하는 과정이므로 비효율적입니다.

     

    case 2와 case 3에서는 s2를 원래의 s1 뒤에 추가하는 개념이므로 실제 실행 효율성이 같습니다.

     

    case 1과 case 2(또는 case 3)의 차이점은 전자가 s1을 먼저 복사한 다음 s2를 복사하여 새로운 string을 생성하는 반면, 후자는 s2만 복사하여 s1 뒤에 추가합니다.

     

    case 1에서는 s1도 복사되고 새로운 string 객체가 추가로 생성되므로, s1과 s2의 값을 변경하고 싶지 않다면 string new_str = s1 + s2를 사용하는 것이 좋습니다.

     

    s1이 수정되는 것이 문제가 되지 않는다면 append나 +=를 사용하여 불필요한 복사를 줄일 수 있습니다.

     

    위 예시에서 s1 = s1 + s2는 매우 비효율적이므로 사용하지 않는 것이 좋습니다!

    3.4.2 string과 리터럴의 결합

    string은 문자 리터럴(character literals)과 문자열 리터럴(character string literals)과 결합할 수 있습니다. 개념은 간단하며, 리터럴을 자동으로 변환합니다.

     

    그러나 string과 리터럴을 결합할 때 + 연산자의 양쪽 중 하나는 반드시 string이어야 합니다.

    string s1 = "hello";
    string s2 = "world";
    string s3 = s1 + ' ' + s2 + "!\n"; // OK
    string s4 = "123" + "567"; // 오류, 두 리터럴을 직접 결합할 수 없음
    string s5 = "123"s + "567"s; // OK, string끼리 결합과 동일
    string s6 = s1 + "aaa" + "bbb"; // OK, s1 + "aaa"로 새 string을 생성하고 "bbb"와 결합
    string s7 = "aaa" + "bbb" + s1; // 오류, "aaa" + "bbb"가 먼저 계산되며 두 리터럴은 결합할 수 없음
    string s8 = s1 + "aaa"; // OK
    string s9 = "aaa" + s1; // OK

    3.5 두 string의 비교

    <, <=, ==, !=, >=, >에 대해 설명하겠습니다.

     

    ==는 이해하기 쉽습니다.

     

    두 문자열의 길이가 같고 내용이 모두 같으면 s1 == s2는 참입니다.

     

    크기를 비교할 때는 string이 '사전식 정렬'을 따릅니다.

     

    두 가지 규칙이 있습니다:

    • s1과 s2의 길이가 다르지만 내용이 같으면 길이가 더 긴 문자열이 더 큽니다.
    • s1과 s2의 길이와 내용이 다르면 앞에서부터 첫 번째로 다른 값을 가진 문자가 큰 문자열이 더 큽니다.

    예시를 들어보겠습니다:

    // 다음은 모두 참입니다
    "aaa" == "aaa" // 같음
    "aaa" != "bbb" // 다름
    "abcd" < "abcde" // 규칙 1
    "abcd" > "abcc" // 규칙 2, d > c
    "abcd" > "abcceeeeee" // 규칙 2, d > c, 오른쪽이 더 길더라도

     

    ==!=로 두 string이 같은지 비교하는 것이 일반적입니다.

     

    string의 크기를 비교할 때는 사전식 정렬이 필요할 때 사용할 수 있습니다.

     

    사전식 정렬의 예시:

    std::vector<std::string> words; // 많은 단어 문자열을 포함: "aaa", "abc", "bbb", ...
    
    std::sort(words.begin(), words.end(), {
        return s1 > s2; // 알파벳 내림차순으로 정렬
    });

    4. string의 단일 문자 작업

    문자열을 처리할 때, 각 문자를 순차적으로 처리하는 것은 매우 흔한 작업입니다.

     

    예를 들어 "abcdefg"라는 문자열이 있을 때, 'f'가 포함되어 있는지 확인하거나, 각 문자를 "bcdefgh"로 이동시키거나, 특수 문자가 있는지 확인하고 싶을 수 있습니다.

     

    어떤 경우든, 전체 문자열을 순회하며 확인해야 합니다.

     

    순회에 대해 말하자면, 당연히 for문이겠죠.

     

    여기서는 주로 필요할 두 가지 방법을 소개합니다.

     

    첫 번째 방법, 범위 지정 순회:

    string s("aaabbbccc");
    for(size_t i = 3; i < s.size(); i++) {
        std::cout << s[i];
    }
    // bbbccc 출력

     

    여기서는 i의 시작과 끝을 우리가 직접 결정할 수 있습니다.

     

    예를 들어 여기서는 i를 3부터 시작합니다.

     

    두 번째 방법, 모든 문자 순회, 여기서는 반복자(Iterator) 문법인 for(declaration : expression) 문법을 사용할 수 있습니다.

     

    콜론 왼쪽은 문자에 대한 선언이고, 오른쪽은 원본 문자열입니다.

    for(char c : s) {
        std::cout << c;
    }
    // aaabbbccc

     

    여기서는 각 문자를 하나씩 스캔하고 char c에 복사하여 저장하므로 원본 s는 변경할 수 없습니다.

     

    s를 변경하고 싶다면, char &c로 바꿀 수 있습니다.

     

    이렇게 하면 s의 각 문자에 대한 참조를 저장하게 됩니다.

    for(char &c : s) {
        c += 1;
    }
    // s = bbbcccddd

    5. string의 몇 가지 작업

    string을 사용할 때 흔히 보게 되는 상황은 문자열이 특정 부분 문자열을 포함하고 있는지 조회하거나, 문자열을 자르고 삽입하는 등의 작업을 하는 것입니다.

     

    다음은 자주 사용되는 API 중 일부입니다:

    s.find(sub_string); // 조회, 발견된 첫 번째 부분 문자열의 위치 반환
    s.replace(pos, length, new_string); // 대체, pos 위치에서 length 길이만큼 새 문자열 new_string으로 대체
    s.substr(pos, length); // 부분 문자열 추출, pos 위치에서 length 길이만큼 추출
    s.insert(pos, new_string); // 삽입, pos 위치에 new_string 삽입
    s.contains(sub_string); // 포함, 부분 문자열 sub_string이 포함되어 있는지 확인 (주의, C++23 이후에만 지원)

     

    다음은 간단한 예시입니다:

    std::string http_url = "http://mycodings.fly.dev/about/";
    
    // 위치 4에서 삽입, 즉 p 다음 위치에 's'를 삽입하여 https://mycodings.fly.dev/about/를 얻음
    http_url.insert(4, "s");
    // 위치 0부터 5까지의 부분 문자열 검사
    assert(http_url.substr(0, 5) == "https");
    // 문자열에 "about"이 포함되어 있는지 확인
    assert(http_url.contains("about") == true);
    
    // "dev" 부분 문자열의 시작 위치 찾기
    size_t pos = http_url.find("dev");
    // 방금 찾은 pos 위치에서 세 문자를 "co.kr"로 대체하여 https://mycodings.fly.co.kr/about/를 얻음
    http_url.replace(pos, 3, "co.kr");

     

    std::string은 많은 API를 제공하며, 각 API에는 많은 오버로드(overloads)가 있습니다.

     

    쉽게 말해, 여러 가지 사용 방법이 있다는 것입니다.

     

    예를 들어 insert는 string이나 char을 삽입할 수 있습니다.

     

    기본적으로 필요한 기능이 무엇인지 발견하면 string 표준 라이브러리가 제공하는지 확인하고, 제공하지 않으면 직접 만들면 됩니다!


    6. string과 숫자의 타입 변환

    문자열을 숫자로 변환하고 싶으신가요?

     

    다음 함수들이 필요합니다(모두 <string> 라이브러리에 있습니다):

    std::stoi // int로 변환
    std::stol // long int로 변환
    std::stoll // long long int로 변환
    std::stoul // unsigned long int로 변환
    std::stoull // unsigned long long int로 변환
    std::stof // float로 변환
    std::stod // double로 변환
    std::stold // long double로 변환
    int a = std::stoi(string("5"));
    double b = std::stod(string("5.5555"));

     

    여기서 주의할 부분은 stou 함수는 없습니다.

     

    숫자를 string으로 변환하고 싶다면 std::to_string()을 사용할 수 있습니다.

     

    간단한 예시는 다음과 같습니다:

    int a = 5;
    std::string s = "a: " + std::to_string(a);
    // s = "a: 5"

     

    만약 "123456"과 같은 string 타입의 숫자를 읽고 각 숫자를 조작하고 싶다면, stoi가 필요하지 않을 수 있습니다.

     

    다음과 같은 작은 팁을 사용할 수도 있습니다:

    string s("123456");
    for(size_t i = 0; i < s.size(); i++) {
        int a = s[i] - '0';
    }

     

    string의 문자는 char이므로, 각 문자는 사실 ASCII 코드의 char입니다.

     

    예를 들어 'A'의 코드는 65이고, '0'의 코드는 48입니다.

     

    그렇다면 string 안의 숫자를 어떻게 얻을까요?

     

    예를 들어 위의 예에서 s[2]는 '3'이고, '3'의 ASCII 코드(51)에서 '0'의 코드(48)를 빼면, 그 차이는 정확히 3입니다.

     

    위의 예는 많은 경우에 문자열을 직접 숫자로 바꾸거나, 숫자를 문자열로 바꾸지 않아도 된다는 것을 보여줍니다.

     

    대신 우리는 각 문자를 처리할 수 있으며, 이 때 새로운 알고리즘 아이디어를 얻을 수도 있습니다.


    끝맺음

    지금까지 std::string의 다양한 기본 사용법에 대해 알아 봤는데요.

     

    잘 사용하지 못하는 부분이 있다면 string API를 꼭 확인해보시기 바랍니다.

     

    그럼.

Designed by Tistory.