[C++] C-style 문자열의 특징

반응형

C-style 문자열

C++을 처음 배울 때, 대부분 아래와 같은 예문을 보게 됩니다.

#include <iostream>

int main(){

    std::cout << "Hello, world!";	// C-style 문자열
    return 0;
}

그리고, 이 예문에 사용된 "Hello, world!"가 C-style 문자열입니다.

 

이것을 좀 더 정확하게 정의하자면,

C-style 문자열은 문자열 타입을 가진 리터럴( literal )이다

라고 할 수 있습니다.

 

여기서, 문자열 타입이란 문자 배열( char array )을 말하고, 리터럴( literal )은 값을 직접 나타내는 프로그램 요소를 가리키는 단어입니다.

 

아래의 예제들을 통해, 이러한 리터럴의 개념을 알 수 있습니다.

#include <cmath>

int main(){

    int variable = 10;          // integer literal
    const int answer = 42;      // integer literal
    double d = std::sin(108.87); // floating point literal passed to sin function
    bool b = true;              // boolean literal
    char c = 'A';               // char literal
    int* null_pointer = nullptr; // null pointer literal
}

첫 번째 줄의 10int 타입 리터럴이고, variableint 타입 변수입니다.
마찬가지로, 'A'char 타입 리터럴이고, cchar 타입 변수입니다.

그리고, 마지막 줄의, 가리키는 객체가 없는 null 포인터라는 것을 의미하는 nullptr 리터럴도 있습니다.

 

이렇게, C++에서 말하는 리터럴은 타입이 정해져 있는 값이라고 할 수 있습니다.

그리고, 이러한 리터럴은, 위의 변수 variable과는 다르게, 메모리에 저장되지 않습니다.

 

그런데, 가끔 기존의 코드를 보면, 아래와 같은 문장을 만날 때가 있습니다.

#include <iostream>
#include <cstring>

int main(){

    char str[] = "Apple3";	// C-style 문자열을 배열 변수에 저장
    const char* pstr1 = str;	// 배열 변수의 주소를 저장
    
    const char* pstr2 = "Apple4";   // ??

    int ret = strcmp(pstr1, pstr2);
    // ...

    return 0;
}

우선, 위의 첫 번째 문장은 합법입니다.

char 배열 타입의 문자열 리터럴 "Apple3"char 배열 타입의 변수 str에 저장하는 문장이니까요.

그리고, 첫 번째 포인터 pstr1을 초기화하는 문장도 자연스럽습니다.
컴파일러가 문자열 변수 str의 주소를 구하기 위해 암시적으로 & 연산사를 호출하고, 이 호출로 구해진 배열의 주소를 pstr1에 저장하고 있으니까요.

 

그런데, 두 번째 포인터 pstr2를 초기화하는 문장에서, 왜 컴파일러는 오류를 발생시키지 않았을까요?

 

다음과 같이, 리터럴 10으로부터 포인터 변수 pInt2를 초기화할 때는 오류가 발생하는데 말입니다.

int A = 10;
int* pInt1 = &A;	// ok
int* pInt2 = &10;	// int 타입 리터럴 10의 주소 대입. error !!

 

C-style 문자열의 두 가지 특징

첫 번째, C-style 문자열의 타입은 char 배열로, 이 배열은, 마지막 원소로, 암시적인 null 종료 문자( null terminator, '\0' )를 갖고 있습니다.

그리고, C와 C++ 에선 이 종료 문자를 통해, 문자열이 종료되었다는 것을 알 수 있습니다.


그래서, 다음의 문자열 "Hello, world!"의 크기는 13이 아니라, 14입니다.

#include <iostream>
#include <cstring>
using namespace std;

int main(){

    //char str[13] = "Hello, world!"; // 배열 크기가 작으므로 오류 !!
    
    char str[14] = "Hello, world!"; // null 종료 문자를 위해 공간이 필요
    int len = strlen( str);
    cout << "string length: " << len << endl;

    return 0;
}

▼출력

string length: 13

위의 결과가 13으로 나온 이유는 strlen 함수가 '\0'을 제외한 문자의 개수를 반환하기 때문입니다.
하지만, 배열 크기를 13으로 하면, 컴파일러는 오류를 발생해서, 배열 크기로 14를 사용해야 한다고 알려줍니다.

 

두 번째, C-style 문자열은, 다른 종류의 리터럴( literal )과 달리, 프로그램의 시작부터 종료까지 존재하는 상수 객체입니다.
즉, 이 문자열은, 변수와 마찬가지로, 메모리 주소를 가진 객체라는 뜻이 됩니다.

( 좀 더 정확히는, 읽기 전용 메모리 블록에 저장됩니다. )

 

그래서, 다음의 pstr2를 초기화하는 문장이 문제를 발생하지 않은 것입니다.

( 아래서 얘기하겠지만, 이 과정에서 컴파일러는 char 배열 타입을 암시적으로 char* 타입으로 변환합니다. )

int main(){

    char str[] = "Apple3";
    char* pstr1 = str;	// 객체의 주소를 저장
    pstr1[5] = '2';	// Apple2로 변경 가능
    
    char* pstr2 = "Apple4";  // 상수 객체의 주소를 저장
    //pstr2[5] = '1';	     // 상수 객체의 값을 바꿀 수 없음. error !!
}

하지만, pstr2char* 타입의 포인터로 선언해 컴파일이 되었다 하더라도, 상수 객체인 "Apple4" 문자열의 값을 변경할 수는 없습니다.

만약, pstr2 포인터를 통해서, 이 문자열을 수정하려고 하면 예외가 발생됩니다.

따라서, C-style 문자열을 포인터를 통해 사용하고 싶으면, const char* 타입으로 선언하는 것이 안전합니다.
( 컴파일러 옵션에 따라, 아예 const char* 타입의 포인터로만 사용해야 하는 경우도 있습니다. )

 

참고로, 다른 타입의 문자열 리터럴도 있습니다.

using namespace std::string_literals;		// for string 리터럴
using namespace std::string_view_literals;	// for string_view 리터럴

int main(){

    std::string cstr = "Apple5"s;	// std::string 타입의 문자열 리터럴
    
    // C++ 17  이후, std::string_view 타입의 문자열 리터럴
    std::string_view cstrView = "Apples6"sv;    
}

위의 "Apple5"sstd::string 타입의 리터럴입니다.
그리고, "Apple6"sv는 C++17에서 도입된 std::string_view 타입의 리터럴입니다.
( 이때, 접미어인 ssv에는 소문자를 사용해야 합니다. )

 

[C++] 문자열을 읽기 전용으로 참조하는 std::string_view

std::string_viewstd::string_view는 문자열을 사용하면서 발생하는, 무거운 복사 과정을 줄이고자 만든 클래스입니다. C++ 17 이상의 버전에서 사용할 수 있는, 이 클래스는 실제 원본 문자열을 읽기 전용

codingbonfire.tistory.com

이 두 리터럴( literal ) 객체는 임시적인 객체로, C-style 문자열과 달리, 사용된 문장을 벗어나면 바로 파괴됩니다.

 

C-style 문자열을 함수에 전달

아래는 함수에 전달한 C-style 문자열을 화면에 출력하는 코드입니다.

void print( char* pstr ){	// char* 타입의 매개 변수
    std::cout << pstr << '\n';
}

int main(){

    print( "Apple4" );
}

그런데, 위의 "Apple4"의 타입은 char 배열인데, 왜 이 C-style 문자열을 전달받는 함수 printchar* 타입을 매개변수를 사용해야 하는 걸까요?

 

C++에서 char[2]char[3]은 서로 다른 타입입니다.

그렇기 때문에, 모든 크기의 char 배열 타입을 받아들일 수 있는 함수를 만들 수 없습니다.

그렇다고, "Apple4" 문자열만을 출력하기 위해, 그때그때 print( char pstr[7] ) 같은 함수를 작성할 수도 없는 노릇입니다.

 

그래서, C++은 이 문제를 해결하기 위해서, char 배열 타입을 char* 타입으로 변환하기로 결정했습니다.

이러한 변환을 배열의 붕괴( array decay )라고 합니다.

 

그래서, 다음과 같이 char 배열 타입의 매개 변수를 갖는 함수를 정의해도, 컴파일러는 이 매개변수의 타입을 char* 타입으로 인식합니다.

void print( char pstr[7] ){	// char pstr[]과 동일
    std::cout << pstr << '\n';
}

위의 매개변수 char pstr[7]char pstr[]과 동일하고, 이때 배열의 크기를 나타내는 숫자 7은 무시되는 방식으로 처리됩니다.

그리고, char pstr[]도 컴파일러는 char* 타입으로 인식하기 때문에, 위는 함수는 결국 다음 함수와 동일합니다.

void print( char* pstr ){	// char* 타입 매개 변수
    std::cout << pstr << '\n';
}

그런데, 이러한 배열의 붕괴는 배열의 크기 정보까지 제거하는 문제점을 만들었기 때문에, 이 문제를 해결하기 위해서 C-style 문자열의 끝에 null 종료 문자가 필요한 것입니다.

 

하지만, 위에서 얘기한 std::string_view 클래스를 사용하면, 이러한 C-style 문자열을 편리하게 사용할 수 있습니다.

#include <string_view>

void print( std::string_view sv){
    size_t sz = sv.size();	// 문자열의 크기
    std::cout << sv << " : " << sz << '\n';
}

이 클래스의 객체는, C-style 문자열을 함수에 전달할 때, 문자열이 복사되는 과정이 없을 뿐만 아니라, 문자열의 크기를 멤버 함수 size를 통해서 쉽게 구할 수 있습니다.

 

 

정리

  • C-style 문자열은 const char 배열 타입의 리터럴( literal )입니다.
  • C-style 문자열은, 다른 리터럴과 달리, 프로그램 시작부터 종료 시까지 유지되는 객체입니다.
  • 함수에 전달 시, C-style 문자열은 const char* 타입으로 붕괴( decay )됩니다.
  • 이러한 C-style 문자열을 다룰 때는 std::string_view 클래스를 사용하는 것이 좋습니다.

 

 

이 글과 관련 있는 글들

null 포인터와 nullptr 리터럴( literal )

 

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유