C언어 포인터는 왜 타입별로 구분되어 있는가?
내용
저번에 준배님이 제네릭 설명하실 때, 제가 주소의 크기는 같은데 왜 Int형 포인터와 double형 포인터를 구분하는지 질문한 적이 있습니다.
그래서 제가 C언어를 예시로 가져왔는데, 이게 어느정도 해답이 될 거 같습니다.
#include<stdio.h>
int main() {
int a = 300000000; // int형 변수에 300000000를 저장
double b = 3.0; // double형 변수에 3.0 저장
int * c = &a; // c는 a의 주소를 저장
double * d = &b; // d는 b의 주소를 저장
int e = a + *c; // a + (c가 가리키는 주소에 저장된 값)
double f = b + *d; // b + (d가 가리키는 주소에 저장된 값)
}
위 코드에서는 int형 변수a에 3_0000_0000을 저장하고, double형 변수 b에는 3.0을 저장합니다.
그리고, int 포인터형 변수 c에는 a의 주소를 저장하고, double 포인터형 변수 d에는 b의 주소를 저장합니다.
마지막으로, int형 변수 e에는 a와 c가 가리키는 주소에 저장된 값을 더한 값,
double 형 변수 f에는 b와 d가 가리키는 주소에 저장된 값을 더해서 넣어줍니다.
한 바이트에 할당된 주소가 32비트일 때, 메모리를 도식화하면 아래와 같은 그림이 됩니다.
한 칸이 한 바이트를 의미합니다.
따라서 int형 변수인 a는 4개의 칸으로, double 형 변수인 b는 8개의 칸으로, 포인터 변수인 d는 4칸으로 표현하였습니다.
한 바이트씩 한 줄로 나열해야되지만, 편의상 아래와 같이 도식화하였습니다.
![](https://blog.kakaocdn.net/dn/bvGdbM/btrJ1Zpy0uG/ga915DVVSTkbANeVwdYvlk/img.png)
아 그래서 포인터가 왜 타입별로 구분되어 있는건데~!
만약 int형 포인터가 가리키는 주소에서 값을 가져온다고 생각해봅시다.
그럼, int형은 4 바이트이기 때문에, int형 포인터가 가리키는 주소에서 4바이트 가져오면 될 것입니다.
반면, double형 포인터가 가리키는 주소에서는 4 바이트가 아닌 8바이트를 가져와야합니다.
즉, int형 포인터든, double 형 포인터든 주소를 저장하기 때문에 크기는 동일합니다만,
해당 주소에서 몇 바이트를 가져올 것이냐가 전혀 다릅니다.
따라서 컴파일 되고, 기계어로 번역될 때 다른 명령어로 번역이 되는거죠.
어셈블리어(Assembly language) 분석
내용
아래는 위 코드를 디어셈블러로 기계어를 어셈블리어로 변환한 코드입니다.
int main() {
/*
main() 함수의 실행을 준비하는 prologue
*/
int a = 300000000; // int형 변수에 300000000를 저장
00711718 mov dword ptr [a],11E1A300h
double b = 3.0; // double형 변수에 3.0 저장
0071171F movsd xmm0,mmword ptr [__real@4008000000000000 (0717B30h)]
00711727 movsd mmword ptr [b],xmm0
int * c = &a; // c는 a의 주소를 저장
0071172C lea eax,[a]
0071172F mov dword ptr [c],eax
double * d = &b; // d는 b의 주소를 저장
00711732 lea eax,[b]
00711735 mov dword ptr [d],eax
// 핵심!
int e = a + *c; // a + (c가 가리키는 주소에 저장된 값)
00711738 mov eax,dword ptr [c]
0071173B mov ecx,dword ptr [a]
0071173E add ecx,dword ptr [eax]
00711740 mov dword ptr [e],ecx
double f = b + *d; // b + (d가 가리키는 주소에 저장된 값)
00711743 mov eax,dword ptr [d]
00711746 movsd xmm0,mmword ptr [b]
0071174B addsd xmm0,mmword ptr [eax]
0071174F movsd mmword ptr [f],xmm0
/*
main() 함수의 실행을 종료하기 위한 epilogue
*/
}
핵심 주석 아래에 있는 코드만 보시면 됩니다.
a + * c를 할 때는, c의 주소에서 4 바이트를 가져와서 eax 레지스터에 저장하고(a의 주소),
a의 주소에서 4바이트를 가져와서 ecx에 저장합니다(a의 값).
그리고, eax 레지스터에 있는 a의 주소에서 4바이트를 가져와서(a의 값) ecx에 있는 값과 더한 다음 e의 주소에 저장해줍니다.
설명이 어렵게 느껴지실 수 있습니다.
간단하게만 보시면, int형 포인터 변수인 c와 double형 포인터 변수에 저장된 주소에서 값을 가져올 때, 명령어가 전혀 다른 것에만 주목하시면 됩니다.
포인터 변수에 저장된 주소로부터 몇 바이트 불러올 건지가 다르기 때문에, 서로 다른 명령어로 변역되는 것입니다.
따라서, 포인터가 동일한 크기의 주소를 저장함에도 타입별로 구분되어 있는 것이죠.
C++ (Call By Value, Call By Reference)
C++
#include <iostream>
int func1(int a) {
a += 1;
return 20;
}
int func2(int * a) {
*a += 2;
return 32;
}
int func3(int & a) {
a += 3;
return 100;
}
int main() {
int t = 5;
t += 3;
printf("Before : %d\n", t);
func1(t);
printf("After func1() : %d\n", t);
func2(&t);
printf("After func2() : %d\n", t);
func3(t);
printf("After func3() : %d\n", t);
}
Before : 8
After func1() : 8
After func2() : 10
After func3() : 13
보시면, main 함수에서는 t에 5를 넣어주고, 3을 더해줍니다.
그리고, func1() 함수를 호출해줍니다.
func1()을 보시면, 파라미터의 타입이 int 타입이고, a에 1을 더해줍니다.
func1()가 반환한 이후에 main()에서 t 값을 출력해보면, 값이 바뀌지 않고 8이 그대로 출력되는 것을 확인할 수 있습니다.
func1() 호출부의 아규먼트 값이 파라미터에 단순히 복사되었기 때문입니다.
다음으로, func2() 함수를 호출해줍니다.
func2()를 보시면, 파라미터의 타입이 int 포인터 형이고, 포인터 변수를 이용하여 값을 2 증가시켜주었습니다.
func1()과는 다르게, a가 t의 주소를 가리키고 있기 때문에, func2()가 반환했을 때, t의 값이 바뀝니다.
다음으로, func3() 함수를 호출해줍니다.
func3()의 파라미터는 int형의 참조자인데요, 참조자를 사용하면, t의 메모리 공간에 a라는 또 다른 이름을 붙인 것처럼 사용할 수 있습니다.
func3() 함수에서는 a에 3을 더해주고, func3()가 반환한 이후 t의 값은 변경되어서 13이 출력됩니다.
C++ 어셈블리 코드
3: int func1(int a) {
/*
func1() 함수의 실행을 준비하는 prologue
*/
4: a += 1;
00A51808 mov eax,dword ptr [ebp+8] // eax 레지스터에 8이 들어감
00A5180B add eax,1
00A5180E mov dword ptr [ebp+8],eax
5: return 20;
00A51811 mov eax,14h
6: }
8: int func2(int * a) {
/*
func2() 함수의 실행을 준비하는 prologue
*/
9: *a += 2; // func3()와 동일
00A51868 mov eax,dword ptr [ebp+8]
00A5186B mov ecx,dword ptr [eax]
00A5186D add ecx,2
00A51870 mov edx,dword ptr [ebp+8]
00A51873 mov dword ptr [edx],ecx
10: return 32;
00A51875 mov eax,20h
11: }
13: int func3(int & a) {
/*
func3() 함수의 실행을 준비하는 prologue
*/
14: a += 3; // // func2()와 동일
00A518D8 mov eax,dword ptr [ebp+8]
00A518DB mov ecx,dword ptr [eax]
00A518DD add ecx,3
00A518E0 mov edx,dword ptr [ebp+8]
00A518E3 mov dword ptr [edx],ecx
15: return 100;
00A518E5 mov eax,64h
16: }
int main() {
/*
main() 함수의 실행을 준비하는 prologue
*/
19: int t = 5;
00A51A48 mov dword ptr [ebp-8],5
20: t += 3;
00A51A4F mov eax,dword ptr [ebp-8]
00A51A52 add eax,3
00A51A55 mov dword ptr [ebp-8],eax
21:
22: printf("Before : %d\n", t);
00A51A58 mov eax,dword ptr [ebp-8]
00A51A5B push eax
00A51A5C push 0A57B30h
00A51A61 call 00A5104B
00A51A66 add esp,8
23: func1(t);
00A51A69 mov eax,dword ptr [ebp-8] // eax에 8이 들어감
00A51A6C push eax
00A51A6D call 00A51190
00A51A72 add esp,4
24: printf("After func1() : %d\n", t);
00A51A75 mov eax,dword ptr [ebp-8]
00A51A78 push eax
00A51A79 push 0A57B40h
00A51A7E call 00A5104B
00A51A83 add esp,8
25: func2(&t);
00A51A86 lea eax,[ebp-8] // eax에 t의 주소값이 들어감
00A51A89 push eax
00A51A8A call 00A510A0
00A51A8F add esp,4
26: printf("After func2() : %d\n", t);
00A51A92 mov eax,dword ptr [ebp-8]
00A51A95 push eax
00A51A96 push 0A57B58h
00A51A9B call 00A5104B
00A51AA0 add esp,8
27: func3(t);
00A51AA3 lea eax,[ebp-8] // eax에 t의 주소값이 들어감
00A51AA6 push eax
00A51AA7 call 00A511BD
00A51AAC add esp,4
28: printf("After func3() : %d\n", t);
00A51AAF mov eax,dword ptr [ebp-8]
00A51AB2 push eax
00A51AB3 push 0A57B70h
00A51AB8 call 00A5104B
00A51ABD add esp,8
29: }
위 어셈블리 코드를 보면,
func1()을 호출할 때, ebp-8 주소에 저장된 값을 eax 레지스터에 넣어주는데요, ebp-8에 저장된 값이 바로 t의 주소값입니다.
따라서 eax 레지스터에는 8이라는 값이 들어가고, eax 레지스터를 스택에 push 해줍니다.
그리고 func1()을 호출해줍니다. 그럼 func1()에서는 복사된 값을 사용하는 것이죠.
다음으로, func2()를 호출할 때에는, ebp-8에 저장된 값을 eax 레지스터에 저장하고, eax를 스택에 push 해줍니다.
그리고 func2()를 호출해줍니다. 즉, eax에는 t의 주소값이 들어가는 것이죠.
func2()에서는 a에 저장된 t의 주소값을 eax에 저장한 후, eax에 저장된 주소로부터 4바이트를 불러와서 ecx에 저장한 다음, 2를 더해주고, t의 주소에 저장해줍니다.
따라서, 2를 더한 값을 t의 주소에 저장해주었기 때문에 func2()가 반환한 이후에도 값이 변경된 것입니다.
그렇다면, 참조자는 어떻게 동작하는 걸까요?
func2()와 비교해보자면 func3()은 func2()와 완벽히 동일합니다.
호출부도 동일하고, 함수 내부의 동작도 동일합니다.
따라서, 포인터든 참조자든 호출 시 주소를 전달하고, 참조자는 컴파일 되었을 때, 포인터와 동일한 방식으로 사용되는 것을 확인할 수 있습니다.
정리하자면, 어셈블리어를 보니 C의 포인터와 C++의 참조자 모두 주소를 전달하는 방식이었습니다.
저의 경우 C/Java는 Call by Value, C++의 참조자는 Call by Reference라고 배웠는데요,
"C는 Call by Reference다", "아니다 주소 '값'을 전달하기 때문에 Call by Value다",
"자바의 Primitive 타입은 Call by Value고 Reference는 Call by Reference다", "아니다 일종의 포인터 역할을 하기 때문에 Call by Value다." 등 책마다, 교수님마다, 사람마다 서로 다른 의견을 갖고 있기 때문에, Call by Value, Call by Reference를 명확히 정의하는 것은 어렵겠습니다만,
제가 생각했을 때는, 주소의 전달 여부가 아닌, 함수 실행부에서 전달 받은 파라미터를 마치 아규먼트의 Aliasing처럼 사용할 수 있느냐로 Call by Reference냐 Call by Value냐를 구분할 수 있을 것 같습니다.
다형성은 어떻게 동작하는가?
다형성은 도대체 어떻게 동작하는 것일까요?
자식 객체를 부모 타입의 레퍼런스 변수에 대입하면, 어떻게 자식의 오버라이딩된 메서드가 호출되는 걸까요?
자바의 다형성을 설명하기 앞서 정적 바인딩에 대해 간략히 설명 드리겠습니다.
정적 바인딩 (Static Binding)
![](https://blog.kakaocdn.net/dn/BpGw5/btrJ29kAvEh/BXr1P4ffKeooOvMmVUEnG0/img.png)
위 예시를 Java가 아니라, 정적 바인딩을 지원하는 가상의 언어라고 생각해봅시다.
정적 바인딩의 경우, 호출하는 함수의 주소가 컴파일 타임에 결정되기 때문에, 컴파일러가 함수의 호출문을 함수의 주소로 점프하는 기계어로 번역하게 됩니다. (어셈블리 코드 참조)
따라서 각 객체들은 함수의 주소를 저장할 필요 없이, 변수들만 저장하면 됩니다.
또한, 이미 결정된 주소로 가서 함수를 단순히 실행시키기만 하면 되기 때문에 오버헤드가 없고, 효율성이 높아집니다.
동적 바인딩 (Dynamic Binding)
다음으로, 동적 바인딩입니다.
동적 바인딩은 정적 바인딩과 달리 컴파일 타임에 바인딩이 이루어지는 것이 아니라, 런타임에 바인딩이 이루어지는 것을 말합니다.
![](https://blog.kakaocdn.net/dn/5j4Ij/btrJ2biShGP/EyCJw5eaUTrBGNxx7jucHK/img.png)
위 예시에서는 Parent 클래스와 Child 클래스가 있습니다.
그리고, Child는 Parent를 상속받았습니다.
아래 1번 코드에서 Parent 타입의 레퍼런스 변수 p에는 Parent 객체가 들어가고,
2번에서 getName()을 호출하면 부모의 getName() 메서드가 호출됩니다.
반면, 3번에서는 Parent 타입의 레퍼런스 변수 c에 Child 객체를 대입합니다.
레퍼런스 변수 c는 이미지에서 빨간색으로 강조 표시해놓은 부분만, 즉, parent에 해당하는 부분만 접근할 수 있습니다.
하지만, 각 객체들은 함수의 위치를 나타내는 테이블을 가지고 있으며, 부모 클래스의 메서드를 오버라이딩한 자식 객체가 부모 타입의 레퍼런스 변수에 대입될 때, 테이블에서의 부모 객체의 메서드 주소가 자식 객체의 메서드 주소로 업데이트 됩니다.
이러한 메서드 호출과 실제 메서드의 바인딩이 런타임에 발생하고, 테이블을 업데이트하는 과정이 실행중에 발생하기 때문에 오버헤드가 발생합니다. 하지만, 다형성을 이용하여 더 유연한 프로그래밍이 가능하죠.
하지만, Java에서도 final, private, static 키워드를 사용하게 되면, 마찬가지로, 메서드의 주소를 컴파일 타임에 알 수 있기 때문에 정적 바인딩이 가능합니다.
참조
- PROGRAMMING LAGUAGE PRAGMATICS, 4th edition
- 🕷lldb 디버거 사용법, 명령어 정리
Java의 배열은 연속적? 그렇다면 왜 Heap에?
심볼릭 레퍼런스 (Symbolic Reference)
제네릭은 어떻게 동작하는가?
'스터디' 카테고리의 다른 글
데코레이터 패턴(Decorator Pattern)이란? (0) | 2023.04.04 |
---|---|
22.11.15 SSAFY 스터디 CS 발표 - 디자인 패턴 (전략 패턴) (0) | 2022.12.01 |
22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴) (0) | 2022.11.07 |
22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치 (0) | 2022.10.11 |
22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조 (0) | 2022.09.22 |