열혈C - Chapter 12 포인터의 이해

2024. 10. 3. 23:42Programming Language/C

12-1 포인터란 무엇인가?

주관적인 내용이 아닌 객관적인 내용을 이해해야한다.

포인터를 이용하면 메모리에 직접 접근이 가능하기에 C언어가 Low레벨언어의 특성을 지닌다고 이야기한다.

 

포인터는 변수가 될수도, 상수가 될 수 도 있다.

그런데 당장은 뭐가 포인터 변수이고 상수임을 논하기엔 이르다.

우리는 지금 당장 포인터 변수를 공부할 것이다.

 

그러면 포인터 변수란 무엇일까?

포인터 변수란 주소값을 저장하는 변수이다.

그리고 이 주소값이란 것은 정수이다.

예를 들어 32bit 시스템이라면 CPU가 한번에 연산할 수 있는 데이터의 크기가 32비트이고, 한번에 이동할 수 있는 데이터의 크기가 32bit라는 것이다. 그래서 보통 32bit 시스템에서는 주소값을 32비트로 표현한다.

반면에 근래에 많이 사용되는 64bit시스템에서는 한번에 연산 가능한 데이터, 한번에 이동 가능한 데이터의 양이 64비트이기에 주소값을 64비트로 표현한다.

그게 가장 표율적이기 때문이다.
물론 32비트 시스템에서도 64비트로 표현이 가능하다.

그러나 그렇게 하지 않는 이유는 비효율적이기 때문이다.

 

주소값을 한번에 연산하지 못하고 이동하지 못한다는건 성능이 떨어진다. 

그러나 주소값이 크면 클수록 좋다.

그 만큼 많은 메모리를 장착할 수 있기 때문이다.

램을 꽃을 때 무작정 많이 꼽는다고 해서 모두 인식되는 것이 아니다.

32비트 시스템에서는 내가 할당 할 수 있는 주소값의 크기가( \(2^32\)개 이고(\(2^n\)인 이유는 바이트 하나당 1과 0으로 표현 가능하기에 그 가짓수의 크기를 말함.) 64비트 시스템의 경우는 \(2^64\)개이다. 이게 내가 메모리를 늘릴 수 있는 크기가 결국 32비트의 경우는 \(2^32bit = 2^2 \times 2^3bit = 4 \times 1GB \)로 4GB정도이고 64바이트 시스템의 경우는 \(2^34 bit \times 2^30bit = 2^34 bit \times 1GB \)의 크기로 매우 큰 사이즈를 갖고 있다)

 

아무튼 주소값은 정수라는 것이다.

그 정수는 int형 변수에 얼마든지 담을 수 있다.

그럼에도 불구하고 왜 int형 변수가 아니라 포인터 변수에 담는 이유가 중요한 것이다...!!

왜 int형 변수가 아니라 포인터 변수라는것을 따로 만들어서 담을까, 다른 말로 왜 주소값을 int형 변수에 담지 않을까?

 

우선 포인터 변수의 선언 방법은 

int num = 10;

와 같이 값을 변수에 저장했을때 이 변수에 대한 주소값을 반환하기 위해 사용되는 연산자는 & 연산자이다.

num이라는 변수의 왼쪽편에 &연산자를 붙여준다.
& 연산자는 사실 scanf 함수를 쓸때 사용하던 연산자이다.
이 연산자를 사용하면 연산의 결과로 그 변수의 주소값이 반환된다.
이걸 int형 변수에도 충분히 담을 수 있으나 int형 변수의 주소값 저장은 포인터 변수에 넣는다.
그 포인터 변수중에서도 int형 포인터 변수에 넣어줘야한다.

 

동일하게 double형 변수의 주소값은 double형 포인터 변수에 저장해줘야한다.

 

그 의미는 저장하고자 하는 주소값의 변수 타입에 맞는 포인터 타입 변수에 넣어줘야한다는 의미이다.

 

근데 int형 변수의 주소값이나 double형 변수의 주소값이나 주소값의 타입은 정수로 동일하다.

 

int형변수의 경우 4바이트로 되어 있고 int형 변수의 주소값은 그 첫번째 바이트의 주소값이 될것이다.

동일하게 double형 변수의 경우는 8바이트로 되어 있고 double형 변수의 주소값은 그 첫번째 바이트의 주소값이 되는 것이다.

그 주소값의 체계는 같은 비트의 시스템의 경우는 같은 크기의 정수로 동일하게 된다.

 

그러면 타입이 다르더라도 결국 주소값은 정수로 동일한 모양 동일한 크기로 구성되어 있을 텐데도 불구하고 왜 포인터를 변수 타입의 타입에 맞춰서 사용해야 하는가 할까?

 

이 두 가지 의문에 답할 수 있어야만 이 포인터에 대한 모든 내용에 대해 이해할 수 있을 것이다.

 

  1. 주소값은 정수의 형태인데 int형 변수에 담을수도 있으면서 왜 포인터 변수란걸 만들어서 따로 저장하게 했을까
  2. 주소값은 정수로 동일할텐데 왜 포인터는 변수의 타입에 맞춰서 선언해서 사용해야 할까

 

여기서 만약 포인터를 32비트 시스템에서 사용한다면 4바이트의 크기의 주소값을 가진 주소값을 저장하기 위해서 4바이트크기를 갖고 있게 된다.

여기서 32비트 시스템의 경우와 64비트 시스템의 경우가 서로 다른데 32비트 시스템이라고 하려면 H/W와 OS와 Compiler까지 32비트의 형태를 띄어야 한다.

아무튼 이렇게 포인터 변수를 선언하면 그 크기는 시스템에 따라 달라진다.

 

어쨋든 일단 우선 적으로 포인터의 사용을 보자면 

#include <stdio.h>

int main(void){
	int num = 10;
    	int * ptr; // 포인터를 선언하는 방법
        ptr = &num; // 포인터에 주소값을 저장하는 방법
}

의 형태로 사용하면 된다.

물론 선언과 동시에 주소값을 할당해줘도 상관은 없다.

근데 이 형태는 결국 그냥 ptr이라는 포인터 변수에 num의 주소값을 넣어두는 기능을 한다는 말 밖에 안된다.

 

이것만 보면 포인터는 하등 쓸모 없는 기능처럼 보인다.

물론 그 쓸모에 대해서 이야기를 아직 하지 않았기 때문이다.

 

이 내용을 이제 복습하듯이 정리해보자.

 

주소 값의 저장을 목적으로 선언되는 포인터 변수

이 코드를 메모리의 형태로 보자면 

이런 형태로 되어 있을것이다.

물론 메모리에 데이터가 나란히 있을 수도 나란히 있지 않을 수도 있으나 저런 모양으로 메모리에 할당되고 주소값은 한 바이트당 하나의 주소값을 갖고 있는데 그 값들이 이런 주소값을 가지게 된다는 의미이다.

그래서 num의 10값을 갖고 있는 주소값은 그 변수의 가장 앞 바이트의 주소값인 0x12ff776을 의미한다.

이런 정수 형태의 주소 값을 저장하기 위해서 선언 되는 것이 포인터 변수이다.

 

포인터 변수와 & 연산자 맛보기

정수 7이 저장된 int형 변수 num을 선언하고, 주소값을 저장하기 위한 포인터 변수 pnum을 선언하자.
그리고 나서 pnum에 변수 num의 주소값을 저장하자

이걸 코드로 바꿔보자면

이렇게 작성할 수 있다.

이건 메모리 할당에 대해서 보자면 

와 같은 형태로 생성이 될 것이다.

보면 num은 int형이기에 4바이트의 크기를 가지나 pnum은 8바이트의 크기를 가지는 것을 볼 수 있다.

포인트 변수인 pnum이 8바이트라는 의미는 시스템이 64비트 시스템을 사용함을 알 수 있다.

이렇게 저장하고자하는 값보다 포인터의 크기가 큰 역전현상은 어디서든 일어날 수 있다. 

char형 변수의 경우는 항상 역전현상이 일어날 것이다.

 

포인터 변수 선언하기

결론 적으로 가리키고자 하는 변수의 자료형에 따라서 포인터 변수의 선언 방법에는 차이가 있다.

포인터 변수에 저장되는 값은 모두 정수로 값의 형태는 동일함에도 불구하고 선언 방법에 차이를 가진다.

그 이유는 메모리 접근과 관련이 있다.

 

포인터의 형(Type)

Type형 포인터 변수의 타입은 Type형 포인터라는 점..!

 

type * ptr ; 	// type형 포인터 변수 ptr
type *		// type형 포인터

 

또한 포인터 변수 선언에 사용되는 *은 위치에 따른 차이는 없다.

type  *ptr;
tpye*  ptr;
type * ptr;

모두 동일한 기능을 한다.

옛날에는 구분 했는데 이제는 아예 상관도 없어보인다고 한다.

 

12-2 포인터와 관련 있는 연산자: &연산자와 *연산자

이번에도 실제 내용에 대해서 들어가기 전에 내용을 먼저 작성해보도록 하자.

만약 

int num = 20;
int ptr = &num;

과 같이 포인터 변수가 아닌 ptr이라는 int형 변수에 int형 변수 num의 주소값을 저장한다면 문제 없이 일단 저장은 될것이다.

 

여기서 ptr에 들어 있는 주소값을 그냥 주소값으로만 사용한다면 아무런 의미도 없는 그냥 정수값이 될것이다.

그러면 결국 이 주소값에 있는 메모리 공간에 있는 값을 사용할 수 있어야 할텐데 이걸 가능하게 해주는 것이 * 연산자이다.

 

* 연산자는 피연산자의 형태에 따라 다른 기능을 수행하는데 

  1. 값 * 값 의  피연산자를 가진다면 곱셈의 연산을 수행한다
  2. 자료형 * 변수명 의 피연산자를 가진다면 포인터 변수의 선언을 수행한다.
  3. * 포인터변수명 의 피연산자를 가진다면 포인터 변수의 주소에 있는 메모리 공간에 접근한다.

우리가 주시해야할 부분은 3번째 기능인 메모리 공간에 접근하는 기능이다.

 

int num = 20;
int * ptr = &num;

과 같이 포인터 변수에 num의 주소값을 넣어준다면 이 값에 접근하기 위해선 

*ptr

과 같이 사용한다.

 

그래서 

*ptr = 30;

이라고 작성한다면 ptr이 가리키고 있는 주소값에 있는 메모리공간에 30을 넣어주겠다는 의미가 되고 그건 결국 

num = 30;

과 동일한 기능을 수행하게 된다 

 

int * ptr = &num;

을 수행하는 순간부터 

*ptr 과 num은 동일한 의미를 가진다고 볼 수 있다.

 

그렇다면 위에서 본

int num = 20;
int ptr = &num;

이 코드에서 *ptr을 사용할 수 있을까?

 

불가능하다. 

그 이유는 ptr이 갖고 있는 주소값에 위치하는 메모리공간에 있는 값이 어떤 형태인지 어떠한 정보도 ptr이 제공할 수 없기 때문이다.

 

아니 ptr이 가리키는걸 *num을 했으면 num은 당연히 int형 변수니까 그 정보를 갖고 있는거 아니야?

아니다 ptr을 사용했다면 그 ptr이 가리키는 주소값에 있는 메모리 공간의 값의 정보는 ptr이 제공해줘야만 한다.

ptr은 그냥 자신이 int형인것에 대한 정보만을 갖고 있고 가리키는 값에 대한 정보를 갖고 있지 않은 것이다.

단순하게 그냥 

int ptr;
*ptr = 17;

만 존재한다고 보자.

그러면 ptr이 가리키는 값이 어떤 것인지 알 수 있을까

알 수 없다.

그 때문에 ptr이 담는 주소값에 대한 정보를 알 수 있게 포인터 변수로 선언해줘야만 한다.

결국 int * 은 ptr에 담는 변수의 형태가 int라는 것을 제공해주는 것이라고 볼 수 있다.

 

여기서 위에서 생각했던 의문점들이 해결되었다.

 

* 왜 주소값을 변수가 아닌 포인터 변수라는 것에 담아야만 하는가?

변수의 경우는 해당 주소값에 위치한 값에 대한 정보를 갖고 있지않다.

포인터 변수를 사용하는 이유는 주소값에 있는 값에 대한 정보를 제공해주기 위함이다.

 

* 왜 포인터 변수는 정수형 주소값을 담음에도 불구하고 타입에 맞는 포인터변수를 선언해야만 하는가?

포인터 변수에 저장하는 주소값에 있는 값에 대한 정보를 제공해주기 위함이다.

해당 주소값에 있는 값의 형태는 포인터 변수를 선언할때 사용한 타입의 형태를 갖고 있음을 알려주기 위함이라고 볼 수 있다.

 

정리하자면 * 연산자는 포인터변수의 형을 참고하여 해당 주소에 있는 값이 포인터 변수의 형의 형태를 가짐을 알 수 있게 된다.

 

변수의 주소값을 반환하는 &연산자

&연산자는 변수의 주소값을 반환하기에 피연산자는 상수가 아닌 변수가 되어야만 한다.

&연산자의 반환값은 포인터 변수에 저장한다.

 

int형 변수 대상의 &연산자의 반환값은 int형 포인터 변수에, double형 변수 대상의 &연산의 반환값은 double형 포인터 변수에 저장한다.

만약 타입을 맞추지 않고 c++컴파일같이 타입 체크가 타이트한 컴파일러를 사용한다면 

이런에러를 출력하긴 하나 형변환을 해주면 충분히 저장을 해준다.

 

그런데 결국 얘는 포인터에 있는 타입으로 인지하기 마련이라는 것이고 이렇게 저장해봤자 결국 값을 불러 낼때는 이게 어떤 값인지 정확하게 파악하지 못한다는 것이 문제이다.

 

포인터가 가리키는 메모리를 참조하는 *연산자

 

그리고 이렇게 한다면 *pnum은 num과 동일하게 볼 수 있다.

그래서 

이렇게 이해할 수 도 있다.

 

다양한 포인터 형이 존재하는 이유

포인터 형은 메모리 공간을 참조하는 힌트가 되고 다양한 포인터 형을 정의한 이유는 * 연산을 통한 메모리의 접근 기준을 마련하기 위함이다.

물론 그값은 원하는 방향이 아니라 이상한 값이 출력된다.

이는 메모리 공간에 있는 형태가 double과 int형이 다르기 때문이고 그걸 타입에 맞게 읽어오지 못하기 때문이다.

 

잘못된 포인터의 사용과 널 포인터

ptr이 쓰레기 값으로 초기화 되기에 200이 저장되는 위치는 어디인지 알 수 없다.

그렇기에 매우 위험한 행동이다.

 

이게 위험한 행동인 이유는 처음 포인터에 쓰레기값이 할당된다면, 이 쓰레기 값이 우리의 앱공간이라면 그나마 괜찮은데 OS의 공간이라면 값을 변경하는 행동이 매우 위험할 수 있다.

그러나 요즘은 OS에 접근해서 값을 변경하려고 한다면 OS가 막아버리기 때문에 위험하지는 않으나 이렇게 코드를 짜면 어디엔가에 값을 저장한다는 의미이고 이건 언젠가는 문제를 발생시킬 수 있다는 의미로 코드로써는 문제가 있는 코드이다 .

 

위와 동일하다고 볼 수 있다. 포인터 변수에 125를 저장했는데 이게 값의 주소값이 아니라 그냥 주소값을 125로 저장한다는 것이다. 125라는 주소값이 어디인지 확인할 수 없기에 위험한 행동이다.

 

물론 이 또한 요즘은 크게 문제가 되지 않으나 결국 저 값이 저장이 되면 어디엔가에 값을 저장한다는 의미이고 이건 언젠가는 문제를 발생시킬 수 있다는 의미로 코드로써는 문제가 있는 코드이다.

 

잘못된 포인터 연산을 막기 위해서 특정한 값으로 초기화하지 않는 경우는 널포인터로 초기화하는 것이 안전하다.

널 포인터 NULL은 숫자 0을 의미하고  0은 0번지를 뜻하는것이 아니라 아무것도 가리키지 않는다는 의미로 해석된다.

 

이럴때 *prt1 = 30과 같이 값을 저장하려고 하면 너 거기에 가리키는거 아무것도 없는데 뭘 넣으려고 하는거야 라고 하면서 프로그램을 종료시킨다.

 

이건 안전한 종료라고 볼 수 있기에 안전한 코드라고 볼 수 있다.