메모리 영역은 다음과 같이 나눌 수 있다.
1. 스택
2. 데이터
3. 힙
4. ROM(읽기 전용 메모리, 코드 영역)
코드에 대해서 이야기하려면 문자 파트에 대해 알아보아야 한다.
- 문자
다음과 같이 char형 변수를 입력하고 디버깅을 해본다. c 메모리 안에 그냥 1이 아닌, 1 '\x1' 이 들어있음을 알 수 있다.
// 문자
char c = 0; // char => 1 바이트 정수타입 자료형으로 문자를 입력하기 위한 전용 자료형이다.
이어서 char의 확장형이자 2 바이트 정수 타입으로 문자를 입력하기 위해 사용하는 wchar_t 자료형을 입력하고 디버깅해 본다.
wchar_t wc = 49;
wc에는 49가 아닌 49'1'이 들어가 있다.
두 자료형에서 ' ' 안에 들어 있는 것은 무엇을 의미할까? 그것은 바로 아스키 코드(ASCII)라는 것이다.
이것은 미국 국제 표준 협회에서 지정한 각 문자에 대응하는 숫자를 맵핑시켜 놓은 표를 의미한다.
이에 따르면 숫자 49를 문자로 표현하면 '1'이 된다. 영문 대문자 A는 '65', 소문자 a는 '97'이다.
실제로 wchar_t wc에 65를 할당하고 디버깅해 보면 내부에 65'A'가, 97을 할당하면 내부에 97'a'가 들어 있음을 알 수 있다.
이를 통해 알 수 있는 사실은 다음과 같다.
컴퓨터에서 문자라는 개념은 '문자 이미지 표를 메모리에 저장해 두고, 특정 숫자가 보이면 그 숫자에 매칭되는 표에 있는 문자를 보여주는 것'이다. 즉, 우리가 한글이나 영어로 글자를 치더라도 메모리 상으로는 그 글자를 대응하는 숫자 데이터로 저장한다는 것이다.
즉, 아래처럼 메모리 상의 숫자 1과 문자로서의 '1'은 서로 다른 것이다.
char c = 1; // 1'\x1'
c = '1'; // 49'1'
이처럼 문자로서의 숫자들은 숫자가 아닌 문자로 봐야 한다.
이런 문자들이 나열되어 있는 것을 '문자열'이라고 한다.
wchar_t wc = 97;
wc = 459;
"459"; // 문자열에는 " " 을 사용한다.
2 바이트 정수의 459라고 한다면 내부 메모리에는 이 숫자를 표현하기 위한 2진수 숫자가 0,1,0,1, .... 형태로 들어있다.
그러나 문자로서의 "459"는 4에 해당하는 문자(52), 5에 해당하는 문자(53), 9에 해당하는 문자(57)가 연달아 들어있다.
이때 메모리 한 칸은 각각 1 바이트를 가진다.
하지만 단순하게 문자만 나열해 버리면 문자의 끝이 어디인지 알 수 없으므로, 현재 필요한 3번째 메모리 이후에도 문자가 있을 것이라고 착각할 수 있다.
따라서 각 문자열에서 어디가 끝인지를 알 수 있어야 한다. 이 역할을 하는 것이 0이다.
문자열 끝에 0이 있으면 해당 위치가 문자열의 마지막임을 알고 그 이전 메모리까지의 문자를 해석해 문자 "459"를 적는다.
+) 0에 대응하는 문자도 있지 않을까?
있다. 그것을 NULL문자라고 한다. 메모리 안에 정수 0이 들어있으면 그것을 문자로는 NULL 문자로 본다.
다만 주의할 것은 이것이 공백 문자와는 전혀 다르다는 점이다.
공백(Space)은 아무것도 없는 것이 아니라 메모리상에 32에 해당하는 1 바이트 정수가 들어있게 된다.
즉, "4 59"라고 적혀있다면 이것은 아래의 그림처럼 메모리상으로 [52], [32], [53], [57], [0]가 들어있는 것이다.
- 문자열
char(1byte), wchar(2byte)
8비트로 표현 가능한 수의 범위는 0~255이지만 UTF-8의 경우 맨 앞 한 자리를 앞 뒤 글자가 몇 바이트인지 나타내기 위해 앞에 1을 써야 하기 때문에 1칸이 줄어든 2^7 = 128로 0~127의 범위를 가지게 된다.
따라서 아스키코드도 127까지 존재하게 된다.
즉 1 바이트로 문자를 표현하면 127까지 표현할 수 있다.
반면 문자 하나를 2 바이트로 표현한다면 대응될 수 있는 값이 2^16 = 65536으로 약 6만 개이며, 이를 절반으로 나눠도 3만이 된다. 표현할 수 있는 문자의 수가 훨씬 더 많아지는 것이다.
문자에 대한 표현 방법은 아래와 같다.
// 문자
char c = 'a'; // 1 바이트 문자
wchar_t wc = L'a'; // 2 바이트 문자
// 문자열
char szChar[10] = "abcdef"; // 1 바이트 문자열
wchar_t szWChar[10] = L"abcdef"; // 2 바이트 문자열
// 마지막에 오는 Null문자열까지 합하여 총 7만큼의 크기를 가짐
// 따라서 szWChar[6] 이라고 선언하는 경우 오류가 발생함
// 문자열의 이런 초기화법은 문자에 해당하는 경우에만 허용됨
// 똑같은 2 바이트 정수 자료형이라도 short에서는 이와 같이
// 초기화 할 수 없음. 문자 자체는 대응 하는 숫자로 저장되기 때문
short arrShort[10] = { 97, 98, 99, 100, 101, 102, }; // szWChar에서의 방식대로 똑같이 short에서
// 초기화 하려고 하면 내부 저장 방식은
// 이처럼 숫자로 저장되는 것과 동일함
szWChar와 arrShort의 내부를 확인해 보면 아래와 같음을 알 수 있다.
따라서 배열 초기화 시 직접 " " 안에 넣어서 하는 경우는 문자에서만 가능하다.
이어서 다음의 코드를 살펴보자. 현재 pChar는 wchar_t 타입의 const 포인터로 선언되어 있는데, 2 바이트 크기의 문자열을 할당했는데도 VS 상에서 아무런 문법적 오류가 발견되지 않고 있다.
const wchar_t* pChar = L"abcdef";
이를 통해 문자열의 정체가 바로 주소값임을 알 수 있다.
- 문자열의 일반 배열과 포인터 배열의 차이
wchar_t szChar[10] = "abcdef"; // 포인터가 아닌 배열 상태. 존재하는 문자열을
// 저장하기 위해 20바이트 (2바이트 10칸)를 준비하고
// 메모리안 데이터를 하나하나 배열로 옮기겠다는 의미이다.
// 즉, 복사 붙여넣기
const wchar_t* pChar = L"abcdef"; // 2바이트 정수 문자에 정확하게 접근하기 위해 2바이트 포인터로 맞춤
// 위의 경우처럼 복사 붙여넣기가 아니라 다이렉트로 문자를 가리킴
아래위의 코드 모두 L자가 붙어있다. 이는 이 문자 데이터들을 한 칸 당 2 바이트씩 사용하겠다는 의미이다.
따라서 2바이트 자료형 포인터를 사용해야 이 주소를 맵핑할 수 있다.
자세히 설명하자면, 주소 변수에 어딘가의 주소를 받으면 그 주소로 갔을 때 문자들이 있는 것이다. (한 칸 당 2 바이트씩 차지) 이때 컴파일러는 실제 데이터 타입이므로 주소 타입이 일치하지 않으면 일반적으로 오류를 낸다. 따라서 문자들의 데이터 타입이 w_char 타입이고 2 바이트 문자이기 때문에 그것에 맞춰 2 바이트 자료형 포인터를 사용하였다.
다음의 예시를 통해 더욱 확실하게 이해할 수 있다.
szWChar[1] = 'z';
- 위의 코드는 무엇을 의미할까?
char 타입 배열 안에는 지역 스택 메모리 쪽으로 초기화한 값을 복사시켜 두었다.
그렇기 때문에 이 경우는 배열의 2번째 칸(0부터 시작하므로)에 있는 'b'를 'z'로 변경하라는 의미이다.
이번엔 아래의 코드를 보자.
// pChar[1] = 'z';
- 위의 코드는 무엇을 의미할까?
사실 이 코드는 잘못된 코드이다. 하지만 코드 자체만 봤을 때는 다음과 같이 해석할 수 있다.
전제 1. wchat_t 타입의 포인터는 배열로 구성된 문자열의 주소를 가리키고 있다.
전제 2. pChar[1]은 *(pChar + 1) = 'z'와 같다.
결론. 즉 'b'를 바꾸려는 것이 아니라, 주소 변수가 다이렉트로 입력된 주소로 접근해 pChar에서 + 1된 주소가 가리키는 것을 b에서 z로 바꾸겠다는 의미이다.
이 말은 아래와 같은 케이스라고 할 수 있다.
10 = 11;
10에 11을 할당하는 것은 불가능하다. 10이라는 것은 코드 그 자체인데, 그것에 11을 넣겠다는 것은 이치에 어긋난다.
마찬가지로, 문자열이라는 것은 어딘가에 있는 데이터를 읽어오는 것이 아니라 내가 작성한, 코드 자체에 적혀있는 구문이다. 따라서
wchar_t szChar[10] = "abcdef";
이 배열의 초기화 구문이 가진 의미는 실제 우리 프로그램이 실행될 때 기준으로 스택 메모리에 wchar_t 타입 10개만큼의 메모리가 잡힐 것이고, 거기에 내가 여기 작성해 놓은 이 문자 데이터를 그 공간에 복사시키라는 의미이다.
이때 아래의 *(pChar + 1) = 'z' 명령어는 다음과 같이 받아들여진다.
'우리 메인 함수 스택에 포인터 변수가 하나 있고, abcdf라고 작성해 놓은 코드 자체가 메모리 어딘가에 존재한다. 거기에 있는 문자 코드의 시작 주소를 이 포인터에 연결하라'는 의미이다.
코드는 우리 프로그램이 실행시켜야 할 어떤 명령어와 같다. 그 명령어 자체도 메모리상에 존재하고 있어야 한다. 그래야 명령어를 읽어 들이며 cpu가 그 명령어를 수행하며 거기에 적힌 대로 동작하기 때문이다.
하지만 명령어 안에 코드 자체에 필요한 데이터가 있기 때문에 그것을 가리키게 만든 것이다.
거기로 접근해서 그곳을 'z'로 수정한다는 것은 실시간으로 프로그램 실행 도중에 코드를 수정하는 것과 마찬가지이다.
여기서 중요한 사실을 한 가지 알 수 있다.
메모리 영역 중 ROM에 저장되는 코드는 읽기 전용(Read-Only) 메모리로 실행 중에 절대로 바뀌어서는 안 된다.
그래서 선언부를 다시 살펴보면 아래와 같이 pChar가 변경될 수 없도록 const 포인터로 선언되어 있다.
const wchar_t* pChar = L"abcdef";
const가 수식하고 있는 것이 wchar_t*이므로 원본 자체가 상수가 되었음을 알 수 있다.
지난 시간에 포인터를 배우면서 짚었던 것처럼 강제 캐스팅을 가하면 단순히 컴파일러 상에서는 문법 오류가 없기 때문에 변경이 가능하긴 하지만, 실행을 하면 코드 영역을 변경하려는 시도 때문에 프로그램이 터지는 문제가 발생한다.
- 멀티바이트
앞서 살펴본 내용에 의하면 문자열에 L자를 붙이지 않을 경우 1 바이트 단위 문자열이 되고 L자가 있는 경우 와이드 바이트라고 하여 모든 문자를 전부 다 2 바이트로 표현하겠다고 선언한 것과 같다고 하였다.
하지만 1 바이트로 표현하는 방식은 엄밀히 말하면 1 바이트로 표현하는 것이 아니다.
Multi Byte Character Set이라고 하는 개념이 존재하기 때문이다.
이것은 문자에 따라서 가변 길이로 대응을 하는 것으로, 특정 문자들을 1 바이트로 표현하고 나머지 2 바이트로 표현하는 문자는 2 바이트로 표현한다. 따라서 L자가 붙지 않았을 경우 모든 문자를 1 바이트로 표현한다는 표현은 엄밀히 말하자면 잘못되었다. 멀티바이트에 의해 중간에 2 바이트 문자가 포함될 수 있기 때문이다.
코드로 예를 들면 다음과 같다.
char szTest[10] = "abc한글";
abc 다음에 한글이 들어오는 형태의 문자열이다. 한글의 경우 매칭되는 인덱스의 숫자가 매우 크다. 따라서 2 바이트로 표현해야 하는데, abc의 경우는 각각의 문자를 1 바이트로 표현할 수 있다.
하지만 멀티바이트 시스템은 이제 쓰이지 않고 있다.
마이크로소프트 윈도우에서만 이전에 개발된 시스템을 위해 어쩔 수 없이 남겨두었고, 현재 표준으로 쓰이고 있는 UTF-8이라는 문자 인코딩 방식과의 호환성 때문에 2 바이트 방식으로 넘어갔다가 또 인코딩을 해줘야 하는 문제들이 있다.
즉, 멀티바이트 시스템은 일반적으로 사용되지 않고 있으며 모든 문자를 2 바이트로 표현하는 와이드 바이트 시스템인 유니코드 문자 셋을 사용하는 것이 다른 부분들과의 호환성을 고려했을 때 훨씬 유리하다.
wchar_t szTestW[10] = L"abc한글"; // 유니코드 와이드 바이트 방식
그렇다면 왜 멀티바이트는 이제 사용되지 않고 있을까?
- 멀티바이트의 문제점
문자가 나열되어 있을 때 메모리 상에 어떤 문자는 1 바이트고, 어떤 문자는 2 바이트일 수 있기 때문에 문자열에 문자의 수가 총 몇 개인지를 체크하고 싶을 때 기준이 모호 해진다. - 그렇다면 문자열 안에 문자가 몇 개 있는지 아는 방법은 뭐가 있을까?
아래와 같이 wchar.h 헤더를 Include 한 뒤 문자열의 길이를 알려주는 기능을 가진 wcslen() 함수를 사용하면 된다.
#include <wchar.h>
{
wchar_t szName[10] = L"Raimond";
int iLen = wcslen();
}
wcslen() 함수의 선언 원형을 보면 다음과 같다.
size_t wcslen(const wchar_t* _String);
여기서 함수가 왜 이런 인자를 요구하고 있을까?
문자열의 길이를 알려주는 함수로서 이것은 범용적으로 쓰이기 위해 어떤 문자를 전달하더라도 길이를 돌려줄 수 있어야 한다. 그렇기 때문에 사용자가 만들어둔 데이터에 접근해서 확인을 해봐야 한다. 이때 문자열의 시작 주소를 알려줘야 데이터에 접근하여 확인을 해볼 수 있다.
이전에 코드 영역이라고 불렀던 것은 엄밀하게 따지면 코드 영역이 아니다. 데이터 초기화 영역이라고 하는 읽기 전용 영역 안에 코드'도' 존재하는 것이다.
지역변수 배열처럼 메인 함수에 초기화 값으로 쓰이는 것들은 이것들만 따로 모아두는 공간이 존재한다. 이를 메모리 초기화 영역이라고 한다.
다시 돌아가서 배열의 시작 주소를 알려주려면 배열의 이름 자체를 넘겨주어야 하므로(배열의 시작 주소 = 배열의 이름) 최종 형태는 다음과 같다.
int iLen = wcslen(szName); // 실행시 7을 출력한다.
그러나 지난 시간에 배웠듯이 포인터를 넘기면 원본 데이터가 훼손될 위험이 있다.
따라서 이 함수는 리턴값으로 길이만 알려주면 되기 때문에 자신을 사용했을 때 원본 데이터가 훼손되지 않을 것이라는 것을 알려주기 위해 const 포인터로 주소를 받아 간다.
이번에는 이 기능을 VS가 제공하는 기본 함수가 아닌 직접 구현한 함수로 실행해 보자.
- 직접 함수 만들기
먼저 반환 타입을 생각해 보자. 글자의 개수를 알려주는 기능에서는 그 개수가 음수가 될 일이 없다.
따라서 unsigned 키워드를 사용한다.
또한 전체 개수를 알 수 없기 때문에 for문 대신 while문을 사용한다.
안에서 반복문 밖으로 빠져나오기 위해 기저 사례로 포인터가 가리킨 주소 안에 든 값이 널문자(0 == '\0')일때 break문을 사용해 빠져나오도록 하였다.
이 경우를 제외한, 즉 안에 값이 들어 있는 경우에는 함수의 내부 변수로 선언한 i값을 증가시켜 다음 반복 사이클로 넘어갈 때 자연스럽게 이후 바로 다음 인덱스 안을 확인하게 한다. 반복문이 종료되면 함수는 지금까지 카운팅한 i의 값을 반환한다.
아래의 전체 코드를 실행하면 이전에 함수를 사용했을 때와 동일하게 7이 출력되는 것을 확인할 수 있다.
unsigned int GetLength(const wchar_t* _pStr)
{
int i = 0;
while (true)
{
wchar_t c = _pStr[i];
if('\0' == c)
{
break;
}
++i;
}
return i;
}
int main()
{
wchar_t szName[10] = L"Raimond";
int iLne = GetLength(szName); // 실행시 정상적으로 7이 출력된다.
return 0;
}
위의 함수를 while 조건식을 활용해 더욱 간략화하면 다음과 같다.
unsigned int GetLength(const wchar_t* _pStr)
{
int i = 0;
while('\0' != _pStr[i])
{
++i;
}
return i;
}
- 문자열 이어 붙이기
VS가 제공하는 함수로 wcscat_s()가 있다. 함수의 선언 원형 중 하나를 살펴 보면 다음과 같다.
errno_t wcscats_s(wchar_t* _Destination, rsize_t _SizeInWords, const wchar_t* _Source)
이때 첫 번째 인자는 복사해 올 문자열로서 이를 수정 가능한 상태로 만들어야 문자열을 이어 붙이기 할 수 있으므로 const 없이 일반 wchar_t 포인터를 사용한다. 반면 문자열을 이어 붙일 때는 포인터로 원본 값을 훼손하지 않도록 const 포인터를 사용해 주었다.
이 함수에는 원형이 여러 개 존재하는데, 이를 설명하기 위해서는 함수 오버로딩에 대해 숙지 해야 한다. 관련해서 아래의 (※) 항목을 참고하면 된다.
※ 함수 오버로딩
함수명이 동일하더라도 인자의 개수가 달라지면 컴파일러가 둘 중 어떤 함수를 호출해야할지 판단할 수 있으므로 중복된 이름의 함수 선언이 가능해진다. 또는 인자 개수가 같더라도 인자 값의 타입이 다르다면 선언이 가능하다. (인자가 하나씩 있는 함수가 두 개있는데, 각각 int와 float을 인자로 삼는다면 서로 타입이 다르므로 선언 가능)
이러한 성질을 함수의 오버로딩이라고 부른다. 추후 클래스에서 함수 재정의를 뜻하는 오버 라이딩과 헷갈릴 수 있는 개념이므로 잘 숙지해야 한다.
다시 본론으로 돌아가서, 문자를 이어붙이는 작업을 수행하기 위해서는 넉넉한 메모리 공간이 필요하다.
우선 아래와 같이 변수를 할당하고 값을 넣어 결과를 확인해 본다.
wchar_t szString[100] = L"abc";
wcscat_s(szString, 100, L"def"); // szString은 이어붙여질 대상(원본),
// 100은 이어붙이려 하는 원본 공간의 사이즈,
// L"def"는 이어붙일 문자를 의미한다.
- 직접 함수 작성해보기
추후 템플릿에 대해 배우면 더 좋은 함수를 작성할 수 있지만, 우선은 지금까지 배운 지식을 활용해 작성해본다.
실행 결과 정상적으로 결과가 출력됨을 알 수 있다.
#include <assert.h> // 디버깅 모드에서 오류가 생기면 경고를 발생시킨다
void StrCat(wchar_t* _pDest, unsigned int _iBufferSize, const wchar_t* _pSrc)
// (이어붙여지기 되는 원본, 원본 공간 사이즈, 붙이기 할 문자)
// 배열의 최대 개수를 받아가는 이유는 배열의 문자 공간이 이미 어느정도 차있을 때 뒤이어서 붙이려면
// 원래 지정된 공간을 초과해 버릴 수 있기 때문에 이를 방지하기 위해서이다.
{
// 작업을 시작하기 전에 문제가 있을지 없을지 부터 체크해 본다.
// 먼저 첫번째 원본 문자를 공간의 길이와 소스 문자의 길이 + 1(Null문자(0) 포함)를 합쳐
// 이것이 버퍼 사이즈를 초과하는지 여부를 체크한다.
// 이전에 우리가 만들어둔 GetLength() 함수를 사용해 해당 기능을 가져온다. (모듈화)
int iDestLen = GetLength(_pDest);
int iSrcLen = GetLength(_pSrc);
// 예외처리
// 이어붙인 최종 문자열 길이가 원본 저장 공간을 넘어서는 경우
if(_iBufferSize < iDestLen + iSrcLen + 1;) // Null문자 공간까지 계산(+1)
{
assert(nullptr); // 버퍼 사이즈보다 나머지 값들의 크기 총합이 크면 경고 처리+프로그램 종료
}
// 문자열 이어 붙이기
// 1. Dest (원본) 문자열의 끝을 확인(문자열이 이어 붙을 시작 위치)
// iDestLen; // Dest 문자열의 끝 인덱스
// 2. 반복적으로 Src 문자열을 Dest 끝 위치에 복사하기
/ 3. Src문자열의 끝 (Null 문자)를 만나면 반복 종료
// 횟수를 알고 있으므로 for문으로 처리
for(int i = 0; i < iSrcLen + 1; ++i) // Null 문자까지 처리하기 위해 +1 해주었음
{
_pDest[iDestLen + i] = _pSrc[i];
}
}
int main()
{
wchar_t szString[10] = L"abc";
StrCat(szString, 10, L"def");
return 0;
}
- 두 문자열을 받아서 양쪽의 문자열을 비교 체크하는 함수(strcmp()) 작성해보기
<조건>
1. 만약 두 문자열이 완벽하게 일치한다면 iRet이 0으로 나옴
2. 만약 같지 않으면 아스키 기준 누가 먼저 앞서냐에 따라 -1, 1이 된다. (우측이 앞서면 1)
3. 두 문자열의 내용이 완벽하게 같은 경우 문자열 길이가 더 짧은 쪽이 앞선다.
EX. "가나" 그리고 "가나디"가 있다면 "가나"가 앞선다.
<정답>
int StrCmp(const wchar_t* _left, const wchar_t* _right)
{
// 양쪽 문자열을 받아온다.
int leftLen = GetLength(_left);
int rightLen = GetLength(_right);
int iLoop = 0; // 반복용 루프 횟수
int iReturn = 0; // 최종 결과값
// 두 문자열 중 짧은 것을 기준으로 반복한다.
// 이때 반복을 돌 때까지 두 문자열의 내용이 같은 경우
// 더 짧은 쪽이 우위를 가져야 하므로 미리 return 값을 -1, 1로 정해준다.
// 만약 서로 동일하다면 처음에 초기화 해준 0이 그대로 리턴될 것이다.
if (leftLen < rightLen)
{
iLoop = leftLen;
iReturn = -1;
}
else if(leftLen > rightLen)
{
iLoop = rightLen;
iReturn = 1;
}
// 본격적으로 두 문자열의 내부를 서로 비교해본다.
for (int i = 0; i < iLoop; ++i)
{
if(_left[i] < _right) // 왼쪽이 더 앞서므로 -1을 리턴한다.
{
return -1;
}
else if(_left[i] > _right[i]) // 아닌 경우 오른쪽이 더 앞서므로 1을 리턴한다.
{
return 1;
}
}
return iReturn;
}
int main(
{
int iRet = StrCmp(L"abc", L"abc");
}