🚐

데이터 타입과 식별자에 대한 나만의 이야기

식별자와 컴파일러

우리는 데이터를 다루기 위해서 리터럴 만을 취급하진 않는다. (리터럴은 "abc", 1, 1.0, 'a' 와 같은 날것의 데이터를 말한다.) 이러한 리터럴을 담기 위한 그릇이 필요하다. 따라서 우리는 식별자를 통해 그릇에 이름을 붙이고 리터럴을 담는다. 그리고 이러한 그릇은 기본적으로 메모리 공간을 칭한다. 처음 C언어를 배울 때 나는 컴퓨터는 이 식별자를 어떻게 인식하는 건지 의아했다. 내가 알기에 컴퓨터는 0과 1의 sequence만을 인식할 수 있기 때문이다. 식별자가 0과 1로 표현되는 걸까? 그러고도 이렇게 빠른 속도가 나올 수 있나? 등의 생각을 했다.
결론적으로 이런 생각들은 근본적인 전제부터 틀렸다. 식별자를 인식하는 것은 컴퓨터가 아니라 컴파일러였다. 컴파일러가 사용자(개발자)를 위해 식별자를 열심히 해석하는 것이었다. 그렇다면 컴파일러는 식별자를 어떻게 해석하는가?
우리는 C언어에서 식별자를 사용하기 위해 '선언'이라는 것을 한다. int a; 혹은 int a = 10; 과 같이 선언 혹은 선언 및 초기화를 진행한다. 이것을 일반적인 방식으로 쓴다면 datatype identifier(=initial value);와 같은 형태가 될 수 있다. 모든 식별자는 이와 같이 선언된다. 적어도 C언어에서는 그 어떤 식별자도 이와 같은 틀을 벗어날 수 없다. (단, const a; 와 같이 데이터 타입을 생략할 수 있지만 이마저도 대부분의 컴파일러가 제공하는 편의성이고 실제로는 const int a;와 같이 인식한다.)

포인터, 배열 타입

이를 통해 한 가지 알 수 있는 사실이 있다. 많은 사람들이 pointer와 배열, 즉 int * a;int a[3];에서 int*int[3]은 데이터 타입이 아니라고 말한다. 그러나 datateyp identifier(=init); 형태를 본다면 int*int[3]은 데이터 타입이다. 실제로 위키피디아 c data types 페이지에 가면 array를
For every type T, except void and function types, there exist the types "array of N elements of type T". ( 위키피디아 - C data type )
이라고 설명하며, pointer를
Every data type T has a corresponding type pointer to T. A pointer is a data type that contains the address of a storage location of a variable of a particular type. ( 위키피디아 - C data type )
이라고 설명한다. 이제 데이터 타입이 무엇인지 생각해보자.

data type

컴파일러가 datatype identifier(=init); 문장으로 식별자를 인식한다는 것은 알았다. 그럼 datatype은 식별자에 대한 무언가일 것이다. 우리는 그릇에 무언가를 담기 위해서는 그 그릇의 특징을 알아야 한다. 그렇기에 그릇이 어떤 녀석인지 컴파일러에게 알려줄 수 있는 유일한 수단은 식별자가 될 것이다. 또한 그릇 뿐만이 아닌 앞으로 그릇에 어떤 녀석이 담길지 알려줄 수 있는 것도 식별자다. 그리고 식별자가 어떤 녀석인지 알려 줄 수 있는 것은 데이터 타입이다. 따라서 우리는 데이터 타입어떤 크기의 메모리 공간에 어떤 종류의 데이터가 담길 것이며, 이 데이터를 어떻게 연산해야 할지 컴파일러에게 알려주는 역할을 한다는 것을 알 수 있다. 간단히 말하면 데이터 타입은 컴파일러에게 데이터의 크기와 데이터 연산 방식을 알려 준다고 할 수 있다.
In computer science and computer programming, a data type or simply type is an attribute of data which tells the compiler or interpreter how the programmer intends to use the data ( 위키피디아 - Data type )
"데이터 타입은 컴파일러에게 데이터를 어떻게 사용할지 알려준다." 이게 데이터 타입 역할의 전부다.
C에서 코딩을 통해 데이터 타입인지 알 수 있는 방법은 식별자 선언, sizeof 연산자, casting 연산자를 사용하는 것이다. int a; sizeof(int); (int)a; 모두 컴파일러는 해석이 가능하다. 마찬가지로 int*도 해석이 가능하다. 그러나 단 하나 배열 타입은 몇 가지 예외가 있다.
1.
int[3] a;는 사용 불가다. C컴파일러는 int a[3];을 올바른 선언 방식으로 정해 놨기 때문이다. 개인적으로는 int[3] a; 와 같이 통일성을 주었으면 좋겠지만 컴파일러 내부적으로 int a[3]이 해석에 유리하기 때문에 저렇게 정했지 않나 싶다. (참고로 만약 int[3] a; 와 같이 쓰려면, int a[2][3][4];int[4][3][2] a; 가 되어야 할 것이다. 이렇게 되면 배열 sizeof 연산을 할 때, sizeof(int[2][3][4])에서 sizeof(int[4][3][2])로 변경되어야 할 것이다.)
2.
sizeof(int[3])은 된다. 12를 값으로 가진다. 그러나 sizeof(int[])는 안 되는데, 이는 배열의 크기가 정해지지 않았기 때문이다.
3.
(int[])a; or (int[3])a; 는 컴파일 에러다. 컴파일러는 배열 타입으로의 형변환을 막고 있다. 에러 메시지도 배열 타입으로의 형변환은 하면 안 된다는 식으로 나와있는데 위험성이 클 뿐만 아니라 (int*)를 써도 충분히 배열과 비슷한 효과를 낼 수 있기 때문에 막아 놓았지 않나 싶다. gcc는 다음과 같은 에러 메시지를 출력한다.
error: cast specifies array type (gcc)

*, [ ]

int*나 int[]를 데이터 타입이 아니라고 말하는 사람들에게는 다음과 같은 이유도 있을 것이라 생각한다. *와 []는 lvalue 혹은 rvalue 혹은 인자, 즉 읽기와 쓰기로 사용할 때는 연산자 역할을 한다. 모두 해당 주소값이 가리키는 메모리 공간을 읽거나 그곳에 쓰는 작업을 하기 위해 사용된다. 그러나 선언을 할 때는 int* p와 int a[3]의 *와 []는 연산자의 역할을 하지 않는다. 이 둘은 선언할 때는 타입 선언자로서의 역할을 한다. 즉 "p는 int를 가리킬 수 있는 pointer 타입으로 선언할 거야"라고 알려 주는 것이고, "a는 int 3개를 요소로 가지는 배열 타입으로 선언할 거야"라고 알려 주는 것이다. 그러므로 *는 타입은 아니고 타입 선언자라고 말할 수도 있지만 사실 int 자체도 "식별자를 정수 타입으로 선언할 거야"라고 알려 주는 타입 선언자라 볼 수도 있다. 따라서 통일성을 위해 int*와 int[]모두 하나의 타입으로 간주하는 것이 옳다고 생각한다. 그리고 명심할 것은 선언할 때와 사용할 때의 *, []는 다른 의미를 가진다는 것이다.

변수, 상수 식별자

이제 포인터와 배열의 차이점에 대해서 언급해보자. 포인터는 변수고 배열은 상수다. 이게 무슨 말이냐면 int i; int* p = &i; int a[3] = {1,2,3}; 에서 p를 사용할 때 컴파일러는 p라는 메모리 공간 안에 있는 data를 읽거나 p라는 메모리 공간에 어떤 데이터를 쓸 것이라고 생각한다. 즉 int* p = &i;는 p라는 메모리 공간 안에 data, &i, 를 집어 넣는 다는 것이다. printf("%x", p);를 한다면 p라는 메모리 공간 안의 data인 &i 를 읽겠다는 의미다. 즉 변수라는 녀석은 컴파일러에게 메모리 공간 안의 데이터를 의미한다. 혹은 메모리 공간 그 자체라고 생각해도 될 것이다.
그럼 배열은 어떤가. 배열 a는 상수다. 따라서 a = something; 과 같은 문장은 에러다. 왜 불가능할까? 단순히 상수이기 때문은 아니다. a 식별자는 메모리 공간을 의미하는 것이 아닌 메모리 공간의 주소값을 의미한다. 주소값 자체를 something으로 바꾼 다는 것은 논리적으로도 이상하다. (a = something;은 어떤 메모리 공간의 주소값을 something으로 바꾼 다는 것을 의미하는 것이 아니라 그냥 주소값이라는 상수 자체를 something으로 바꾼다는 것을 의미한다. 즉 1을 2로 바꾸겠다는 것이다. 이는 앞으로 '하나', '한 개', ... 이런 것들을 2로 쓰겠다는 의미다. 따라서 컴파일러는 이러한 논리적인 모순을 막아 놓은 것이다.)
이를 통해 우리는 다음과 같은 사실을 알 수 있다.
식별자는 2가지 종류가 있다.
1.
하나는 '변수', 메모리 공간이고
2.
둘은 '상수', 주소값이다.
여기서 오해하지 말아야 할 것은 int * const p; 는 본질은 '변수', 즉 메모리 공간이다. 그러나 const라는 성질을 부여하여 해당 변수를 변경 (쓰기) 작업을 불허한다는 의미다. (상수화 변수라고 할 수 있다. 일상적인 예시를 들어보면 여장 남자, 남장 여자가 각각 진짜 여자, 남자가 아닌 것과 같다.)

포인터와 배열 그리고 차원

나는 차원을 "몇 번의 간접 참조가 허용되는가"로 정의하고자 한다.
즉 int **p는 2차원이고, int a[3][4]도 2차원이다. int* p; 는 1차원이고 int a[3];도 1차원이다. 또한 int a는 0차원이다.
과학, 공학을 하는 사람들은 통일하는 것을 좋아한다. 물리에서는 여러 갈래의 힘을 하나로 통일하고자 하는 시도가 있으며 컴퓨터 또한 시스템을 같은 혹은 비슷한 체계로 통일해서 사용한다. 이러한 통일성이 (혹은 규칙성이) 더 효율적이기 때문일 것이다. 따라서 나는 포인터와 배열 그리고 나머지 변수들을 차원이라는 것으로 묶으려고 한다.
우리가 알고 있는 포인터는 n차원 변수다. (n > 0) 그리고 우리가 알고 있는 배열은 n차원 상수다.
0차원은 무조건 변수다.
*와 []은 선언할 때는 차원을 늘리는 역할을 하고, 사용할 때는(연산자로 쓰일 때는) 차원을 하나 내리는 역할을 한다. (차원을 늘린다는 얘기는 참조를 몇 번 할 수 있는지를 결정하는 것이고, 내린다는 얘기는 참조를 통해 참조 횟수를 하나 소모한다는 뜻이다.)
&는 연산자로만 쓸 수 있으며 차원을 하나 올리는 역할을 한다. 또한 &연산자로 얻은 값은 무조건 주소값, 즉 상수를 얻는다. (차원을 올린다는 얘기는 참조 횟수를 하나 늘린다는 뜻이다.)
definition: " *(a+i) if and only if a[i] "
/* 포인터, 변수 */ int i = 10; int *p = &i; int **pp = &p; int ***ppp = &pp; // n차원 변수: n번 참조할 수 있는 변수 p; // 1차원 변수, 1차원 포인터, 변수기 때문에 메모리 공간을 의미한다. 즉 이 경우엔 p라는 메모리 공간을 의미한다. *p; // 0차원 변수, 변수기 때문에 메모리 공간을 의미한다. 즉 이 경우엔 i라는 메모리 공간을 의미한다. pp; // 2차원 변수, 2차원 포인터, 변수기 때문에 공간을 의미한다. 즉 이 경우엔 pp라는 공간을 의미한다. *pp; // 1차원 변수, 1차원 포인터, 변수기 때문에 공간을 의미한다. 즉 이 경우엔 p라는 공간을 의미한다. **pp; // 0차원 변수, 변수기 때문에 공간을 의미한다. 즉 이 경우, i라는 공간을 의미한다. ppp; // 3차원 변수, 3차원 포인터 *ppp; // 2차원 변수, 2차원 포인터 **ppp; // 1차원 변수, 1차원 포인터 ***ppp; // 0차원 변수, /* 배열, 상수 */ // 단, 0차원은 변수. int a[2] = {1,2}; int aa[2][3] = {{1,2,3},{4,5,6}}; int aaa[2][3][4] = {{{1,2,3,4},{5,6,7,8},{9,10,11,12}}, {{13,14,15,16},{17,18,19,20},{21,22,23,24}}}; a; // 1차원 상수, 1차원 배열, 상수기 때문에 주소값을 의미한다. &a[0]와 값이 같다.(의미는 다르다. sizeof를 했을 때, 배열로 인식할지, int라는 상수로 인식할지의 차이가 있다.) *a; // 0차원 변수, 변수기 때문에 메모리 공간을 의미한다. 즉 1이라는 값이 들어있는 메모리 공간을 의미한다. aa; // 2차원 상수, 2차원 배열, 상수기 때문에 주소값을 의미한다. 즉 &aa[0]와 같다. *aa; // 1차원 상수, 1차원 배열, 상수기 때문에 주소값을 의미한다. 즉 &aa[0][0]와 같다. **aa; // 0차원 변수, 1이라는 값이 들어 있는 메모리 공간을 의미한다.
C
복사

포인터, 배열의 증감 연산

정의: 식별자 + i ⇒ 컴파일러는 식별자 + i * sizeof(하위 차원의 변수(상수))
예) int a[2][3][4];
1.
a + 2 ⇒ For compiler, a + 2 * sizeof(int[3][4]) // a는 3차원 배열이므로 하위 차원은 2차원 배열 int[3][4]
2.
a[0] + 2 ⇒ For compiler, a[0] + 2 * sizeof(int[4]) // a[0]는 2차원 배열이므로 하위 차원은 1차원 배열 int[4]
3.
a[0][0] + 2 ⇒ For compiler, a[0][0] + 2 * sizeof(int) // a[0][0]는 1차원 배열이므로 하위 차원은 0차원 변수 int
4.
a[0][0][0] + 2 ⇒ For compiler, a[0][0][0] + 2 // a[0][0][0]는 0차원 변수이므로 하위 차원은 없다. 따라서 그냥 그대로

포인터와 배열의 복합체

연산자 우선순위 ⇒ [ ] > * (선언할 때 선언자로서의 우선순위도 이와 같다.)
해당 식별자가 포인터인지 배열인지 판단.
몇 차원 포인터 혹은 배열인지, 몇 차원 변수 혹은 상수인지 판단.
ex)
int *a[3]; // []가 우선순위가 더 높다. 따라서 배열이다. // 즉, int* a[3]; 배열이다. // 1차원 배열이고, 2차원 상수다. int (*p)[3]; // ( )로 인해 *가 먼저 식별자 p를 가져간다. 따라서 포인터다. // 즉, int[3]을 가리키는 포인터다. // 1차원 포인터고, 2차원 변수다. int ***a[2][3][4]; // 배열이다. a; // 3차원 배열이고, 6차원 상수다. => 증감 연산시 +- sizeof(int[3][4]) *a; // 2차원 배열이고, 5차원 상수다. &a[0][0]와 같다. => 증감 연산시 +- sizeof(int[4]) **a; // 1차원 배열이고, 4차원 상수다. &a[0][0][0]와 같다. => 증감 연산시 +- sizeof(int) ***a; // 3차원 포인터고, 3차원 변수다.=> 증감 연산자 +0ief int **(*a)[2][3][4]; // (== int**[4][3][2]* a; 이 형태에서 타입 추정은 오른쪽부터) // int**[4][3][2]* a;는 문법적으로 틀리며, 이해를 돕기 위한 형태다. // int **(*a)[2][3][4]; 에서는 식별자와 가까운 것부터 타입 추정을 하며, // 같은 거리라면 [](배열 연산자)가 우선순위가 더 높다. a; // 1차원 포인터고, 6차원 변수다. => a: int**[4][3][2]*, 즉 pointer *a; // 3차원 배열이고, 5차원 상수다. => *a: int**[4][3][2], 즉 원소 2개짜리 배열 **a; // 2차원 배열이고, 4차원 상수다. => **a: int**[4][3], 즉 원소 3개짜리 배열 ***a; // 1차원 배열이고, 3차원 상수다. => ***a: int**[4], 즉 원소 4개짜리 배열 ****a; //2차원 포인터, 2차원 변수다. => ****a: int**, 즉 pointer *****a; //1차원 포인터, 1차원 변수다. => *****a: int*, 즉 pointer ******a; //0차원 포인터, 0차원 변수. => ******a: int, 즉 변수
C
복사