2020. 1. 6. 14:05ㆍC,C++
SoEn:소프트웨어 공학 연구소
www.soen.kr
10-1 포인터 연산
10-1-가. T형 포인터
포인터는 메모리의 한 지점, 간단히 말해 번지값을 가지는 변수이다.
임의의 타입 T가 있을 때 T형의 포인터 변수를 선언할 수 있다.
int, char, double 등의 기본적인 데이터 타입에 대해 int *, char *, double *형의 변수를 선언할 수 있음은 물론이고 구조체, 공용체, 배열에 대해서도 포인터형을 만들 수 있다.
T가 어떤 타입이든지 상관없이 변수 이름 앞에 *구두점만 붙이면 T형 포인터 변수를 선언할 수 있다. * 구두점은 다음 두 가지 형식으로 표기할 수 있다.
① int *pi;
② int* pi;
int*를 하나의 타입으로 볼 때는 ②번 형식을 주로 사용하며 변수가 포인터형임을 강조할 때는 ①번 형식을 사용한다.
int *i,j; // i는 포인터, j는 정수형
int* i,j; // i는 포인터, j는 정수형
int *i,*j; // i와 j 모두 포인터
C 스팩 문서는 모두 ①번 형식으로 되어 있고 C++ 스팩 문서에는 두 형식이 혼재하되 ②번이 좀 더 우세하다.
10-1-나. 포인터의 타입
포인터가 가리키는 번지에 들어있는 값, 즉 포인터가 가리키는 실체를 대상체(object)라고 한다.
포인터 변수는 크기와 형태가 이미 고정되어 있는데 왜 정수를 가리키는 포인터, 실수를 가리키는 포인터 식으로 대상체의 데이터형을 꼭 밝혀야 할까? 포인터라는 고유의 타입을 나타내는 키워드를 새로 정의하고 Pointer p; 식으로 선언할 수도 있는데 말이다. 물론 이유가 있다. 포인터가 타입을 가져야 하는 이유는 다음 두 가지이다.
첫 번째 이유는 *연산자로 포인터의 대상체를 읽거나 쓸 때 대상체의 바이트 수와 비트 해석 방법을 알아야 하기 때문이다.
*연산자는 포인터가 가리키는 곳의 대상체를 읽는 연산자이다. 이 연산자가 제대로 값을 읽기 위해서는 대상체의 타입을 정확하게 알고 있어야 한다.
포인터가 대상체의 타입을 요구하는 두 번째 이유는 인접한 다른 대상체로 이동할 때 이동 거리를 알기 위해서이다. 이동 거리란 곧 대상체의 크기에 대한 정보를 의미한다. 번지를 가리키는 포인터도 일종의 변수이므로 실행중에 다른 번지를 가리키도록 변경할 수 있다.
T형 포인터 변수 px에 정수 i를 더하면
px=px+(i*sizeof(T))가 된다.
10-1-다. 포인터 연산
1) 포인터끼리 더할 수 없다.
2) 포인터끼리 뺄 수는 있다.
3) 포인터에 정수를 더하거나 뺄 수 있다.
4) 포인터끼리 대입할 수 있다. 정수형 포인터 p1, p2가 있을 때 p1=p2 대입식으로 p2가 기억하고 있는 번지를 p1에 대입할 수 있다.
5) 포인터와 실수와의 연산은 허용되지 않는다.
6) 포인터에 곱셈이나 나눗셈을 할 수 없다.
7) 포인터끼리 비교는 가능하다.
10-1-라. *ptr++
ptr이 포인터형 변수일 때, *ptr++은 아주 빈번히 사용되는 문장이며 C언어의 특징을 잘 표현하는 전형적인 포인터 연산문이다.
*ptr++에서 ++보다 *가 먼저 연산되고 ptr이 다음 번지로 이동한다.
*(ptr)++은 완전 다른 식이다. 이러면 ++의 대상이 ptr이 아니라 *ptr이 된다.
ex) 배열의 합계 구하기
C의 이런 축약적 표현은 익숙해지면 아주 편리하나 최근에는 코드를 읽기 어렵게 만든다는 주장이 제기되기도 하였다.
10-2 void형 포인터
10-2-가. void형
포인터형 변수는 선언할 때 반드시 대상체의 타입을 밝혀야 한다. 가리키는 대상체의 타입을 알아야 *연산자로 대상체를 읽을 수 있고 증감 연산자로 전후 이동이 가능하다.
대상체의 타입을 명시하지 않는 특별한 포인터형이 있는데 이것이 바로 void형 포인터이다. void형 포인터를 선언할 때는 void *타입을 지정한다.
void *vp;
1)임의의 대상체를 가리킬 수 있다.
oid형 포인터는 어떠한 대상체라도 가리킬 수 있다. 그래서 pi가 정수형 포인터 변수이고, pd가 실수형 포인터 변수이고 vp가 void형 변수일 때 다음 대입문들은 모두 적법하다.
vp=pi;
vp=pd;
void형 포인터를 좀 더 쉽게 표현하자면 임의의 대상체에 대한 포인터형이다. 대상체가 정수든, 실수든 가리지 않고 메모리 위치를 기억할 수 있다. void형 포인터는 임의의 포인터를 대입받을 수 있지만 반대로 임의의 포인터에 void형 포인터를 대입할 때는 반드시 캐스팅을 해야 한다.
pi=(int *)vp;
pd=(double *)vp;
2) *연산자를 쓸 수 없다.
void형 포인터는 임의의 대상체에 대해 번지값만을 저장하며 이 위치에 어떤 값이 들어 있는지는 알지 못한다. 따라서 *연산자로 이 포인터가 가리키는 메모리의 값을 읽을 수 없다.
3) 증감연산자를 쓸 수 없다.
대상체의 타입이 정해져 있지 않으므로 증감 연산자도 곧바로 사용할 수 없다.
void형 포인터의 특징에 대해 간단하게 요약해 보자. 대상체가 정해져 있지 않으므로 임의의 번지를 저장할 수 있지만 *연산자로 값을 읽거나 증감 연산자로 이동할 때는 반드시 캐스트 연산자가 필요하다. 값을 읽거나 전후 위치로 이동하는 기능은 빼고 순수하게 메모리의 한 지점을 가리키는 기능만 가지는 포인터라고 할 수 있다.
10-2-나. void형 포인터의 활용
포인터로 액세스해야 할 대상체가 분명히 정해져 있을 때는 해당 대상체형의 포인터 변수를 사용하면 된다. 예를 들어 정수형의 ar 배열을 액세스할 때는 정수형 포인터를 사용하고 문자열을 다루고 싶을 때는 문자형 포인터를 쓴다. 그러나 모든 상황에서 대상체를 미리 결정할 수 있는 것은 아니며 임의의 대상체에 대해 동작해야 할 경우가 있다.
대표적으로 메모리를 특정한 값으로 채우는 memset 함수를 보자.
void *memset(void *s, int c, size_t n);
만약 void형 포인터가 없다면 각각의 타입에 대해 memsetint, memsetchar, memsetdouble 같은 함수를 따로따로 만들어야 하므로 무척 불편할 것이다. memset 함수가 임의의 타입에 대해 메모리 채우기를 하기 위해서는 임의의 대상체에 대한 포인터를 모두 전달받을 수 있어야 하며 이럴 때 사용하는 것이 바로 void *형이다.
10-2-다. NULL 포인터
NULL 포인터는 0으로 정의되어 있는 포인터 상수값이다. 아주 특수한 시스템에서는 0이 아닐 수도 있지만 일반적으로 0이라고 생각하면 큰 무리가 없다.
어떤 포인터 변수가 NULL값을 가지고 있다면 이 포인터는 0번지를 가리키고 있는 것이다. 0번지라면 메모리 공간의 제일 처음에 해당하는 첫 번째 바이트인데 이 위치도 분명히 실존하는 메모리 공간이므로 포인터가 0번지를 가리킬 수도 있다. 그러나 대부분의 플랫폼에서 0번지는 ROM이거나 시스템 예약 영역에 해당되므로 응용 프로그램이 이 번지에 어떤 값을 저장하거나 읽을 수 없도록 보호되어 있다.
시스템 영역에 응용 프로그램이 고유의 데이터를 저장할 수는 없으므로 포인터 변수가 0번지를 가리키는 상황은 발생할 수 없다. 그래서 이런 상황은 일종의 에러로 간주되며 그렇게 하기로 약속되어 있다.
포인터와 상수를 직접 연산할 수 없다는 것은 쉽게 이해가 되는데 이 규칙의 예외가 존재한다. 바로 NULL이다. NULL은 실제로 0으로 정의된 정수 상수이지만 이 상수는 아주 특별하게도 포인터 변수와 직접적인 연산이 허용된다. ptr=NULL;이라는 대입문은 ptr을 무효화시키며 if (ptr == NULL)이라는 비교 연산문은 ptr이 무효한지 아닌지를 검사하는 적법한 문장이다.
10-3. 동적 메모리 할당
10-3-가. 할당의 필요성
프로그램이 실행되기 위해서는 메모리가 필요하다. 실행 파일 자체가 메모리에 로드(Load)되어야 실행될 수 있음은 물론이고 프로그램이 작업을 위해 선언하는 변수들도 모두 메모리에 할당된다.
int Score;
double Rate;
컴파일러는 이 두 변수들이 값을 제대로 보관할 수 있도록 변수의 타입에 맞는 크기만큼 메모리를 할당할 것이다. Score변수는 정수형이므로 4바이트가 할당되고 Rate는 실수형이므로 8바이트가 할당된다. 이런 식으로 프로그램을 작성할 때 미리 메모리 필요량을 알려주는 할당을 정적 할당(Static Allocation)이라고 한다. 정수형이나 실수형은 아주 작으며 이런 변수들 때문에 정적 할당이 실패하는 경우는 극히 드물다.
동적 할당(Dynamic Allocation)이란 프로그램을 작성할 때(Compile Time 또는 Design Time) 메모리 필요량을 지정하는 정적 할당과는 달리 실행중에(Run Time) 필요한만큼 메모리를 할당하는 기법이다.
메모리 필요량을 프로그램 작성시에 전혀 예측할 수 없는 경우가 있다. 이 성적 처리 프로그램이 특정한 학교를 위한 것이 아니라 임의의 학교에 대해서 사용할 수 있는 일반적인 응용 프로그램이라고 해 보자. 학생이 10명도 채 안되는 시골 학교가 있는 반면 수만명이나 되는 경우도 있을 것이다.
그렇다고 해서 배열 크기를 100만 정도로 충분하게 정적 할당하는 것은 메모리를 지나치게 낭비하게 되므로 좋지 않다. 문제의 핵심은 필요한 메모리양이 많다는 것이 아니라 얼마나 필요한지 미리 알 수 없다는 것이다. 이런 경우는 실행중에 필요한 메모리양을 판단해서 학생수만큼만 메모리를 할당해야 한다
동적 할당이 필요한 또 다른 경우는 임시적인 메모리가 필요할 때이다. 예를 들어 텍스트 파일에서 특정 문자열이 있는지만 알고 싶다고 하자. 텍스트 파일을 검색하려면 일단 이 파일을 읽어야 하고 그러기 위해서는 텍스트 파일을 읽기 위한 버퍼가 필요하다. 이런 버퍼를 미리 정적 할당해 놓을 필요없이 파일 크기만큼만 동적 할당한 후 원하는 작업만 하고 해제하면 된다.
char *buf=동적할당(파일크기만큼)
buf에 파일 읽음
원하는 작업 - buf에 문자열이 있는지 조사
buf 해제
10-3-나. 메모리 관리 원칙
메모리의 실체는 시스템에 장착되어 있는 RAM이다. 시스템에 따라 RAM 장착양은 다른데 돈이 많은 사람은 자신의 컴퓨터를 위해 많은 RAM을 장착할 것이고 가난한 사람들은 그렇지 못할 것이다.
복수 개의 프로그램이 꼭 필요한큼의 메모리를 충돌없이 사이좋게 잘 사용하려면 정교한 메모리 관리 원칙이 필요하다.
1) 메모리 관리의 주체는 운영체제이다. 응용 프로그램은 직접 메모리를 관리할 수 없으며 메모리가 필요할 경우 운영체제에게 할당 요청을 해야 한다.
2) 운영체제는 메모리가 있는 한은 할당 요청을 거절하지 않는다. 만약 요청한 양만큼 메모리가 남아 있지 않을 경우는 에러를 리턴하여 응용 프로그램에게 메모리가 없다는 것을 알려준다.
3) 한 번 할당된 메모리 공간은 절대로 다른 목적을 위해 재할당되지 않는다. 운영체제는 메모리 공간을 누가 얼마만큼 사용하고 있는지 모두 기억하고 있으며 반납하기 전에는 응용 프로그램이 이 공간을 독점적으로 사용할 수 있도록 보장한다.
4) 응용 프로그램이 할당된 메모리를 해제하면 운영체제는 이 공간을 빈 영역으로 인식하고 다른 목적을 위해 사용할 수 있도록 한다.
10-3-다. 할당 및 해제
메모리를 동적으로 할당 및 해제할 때는 다음 두 함수를 사용한다.
void *malloc(size_t size );
void free(void *memblock );
10바이트가 필요하면 malloc(10)이라고 호출하고 1000바이트가 필요하면 malloc(1000)이라고 호출하면 된다.
free 함수는 동적으로 할당한 메모리를 해제한다. 응용 프로그램은 메모리를 다 사용한 후에 반드시 free 함수를 호출하여 메모리를 해제해야 한다. 그래야 이 영역이 다른 프로그램을 위해 재활용될 수 있다.
. 동적 할당이란 사실 굉장히 간단한 것이다. 필요한만큼 malloc으로 할당해서 쓰다가 다 쓰고 나면 free로 해제하기만 하면 된다.
malloc 함수는 할당에 실패하면 에러의 표시로 NULL을 리턴하며 그래서 이 함수를 호출할 때는 위 예제처럼 malloc이 리턴한 번지를 반드시 점검하는 것이 원칙이다. 메모리가 부족한 상황은 언제든지 발생할 수 있고 만약 이 점검을 하지 않으면 0번지를 액세스할 위험이 있다.
작은 메모리를 할당할 때는 에러 점검을 생략해도 큰 무리가 없다. 얼마 정도가 작은지에 대한 명확한 기준은 없지만 일반적으로 메가 단위 이상을 할당할 때는 꼭 점검해야 하며 수십~수백 바이트 정도는 굳이 점검하지 않아도 상관없다.
다음은 할당된 메모리를 해제하지 않았을 때의 문제점에 대해 알아보자. malloc으로 할당만 하고 free를 하지 않으면 메모리 관리 원칙상 이 메모리는 시스템을 재부팅하기 전에는 다른 응용 프로그램이 사용하지 못한다.
그러나 32비트 운영체제는 메모리를 할당한 프로그램이 종료되면 해제하지 않은 메모리를 알아서 회수하도록 되어 있으므로 16비트에서처럼 큰 문제가 되지는 않는다.
그래도 괜찮다는 얘기는 절대로 아니다. 다만 16비트 환경보다는 문제가 덜하다는 것뿐이지 할당 후 해제하는 것은 프로그래밍의 대원칙이다. malloc후에 항상 free하는 것을 잊지 말아야 하며 malloc 코드를 칠 때 아래 쪽에 free를 먼저 입력해 놓고 다음 작업을 하는 습관을 들이는 것이 좋다.
10-3-라. 재할당
다음 함수(씨얼록이라고 읽는다)는 malloc 함수와 마찬가지로 메모리를 할당하되 필요한 메모리양을 지정하는 방법만 다르다.
void *calloc( size_t num, size_t size );
구조체같은 큰 데이터의 배열을 할당할 때는 calloc으로 할당하는 것이 더 보기에 좋고 코드를 읽기에도 좋다.
calloc이 malloc과 또 다른 차이점은 메모리 할당 후 전부 0으로 초기화한다는 것이다.
다음 함수는 이미 할당된 메모리의 크기를 바꾸어 재할당한다. 최초 할당한 크기보다 더 큰 메모리가 필요할 때는 이 함수로 크기를 조정할 수 있다. 원래 크기보다 더 작게 축소 재할당하는 것도 가능하기는 하지만 보통은 확대 재할당하는 경우가 많다.
void *realloc( void *memblock, size_t size );
네트워크를 통해 파일을 전송하는 프로그램을 작성한다고 해보자. 네트워크의 반대편에서 보내는 파일을 받아야 하는데 이 파일의 크기는 다 받아 보기 전에는 알 수 없는 상황이다. 이럴 때는 최초 적당한 크기로 버퍼를 할당한다. 가령 1M 정도만 할당한 채로 네트워크로 들어오는 패킷을 이 버퍼에 누적시킨다. 그러다가 받은 패킷 총량이 1M가 되면 다시 1M 더 늘려 2M로 재할당한다. 이런 식으로 패킷을 다 받을 때까지 계속 재할당하면 된다. 이런 상황은 생각보다 훨씬 더 자주 발생하는데 압축을 해제한다거나 DB 쿼리를 실행할 때도 재할당이 필요하다.
할당한 메모리가 충분한지를 조사하고 싶을 때 이 함수가 유용하게 사용된다. 표준 함수는 아니지만 비주얼 C++, Dev-C++ 등 웬만한 컴파일러들은 이 함수를 제공하므로 이식성에 대해서는 걱정하지 않아도 된다.
size_t _msize(void *memblock);
(** 리눅스 GCC에는 _msize가 없다! malloc_usable_size를 쓰면 되나?*)
http://man7.org/linux/man-pages/man3/malloc_usable_size.3.html
malloc_usable_size(3) - Linux manual page
man7.org
10-4. 이중 포인터
10-4-가. 이중 포인터
이중 포인터란 포인터 변수를 가리키는 포인터라는 뜻이며 다른 말로 하면 포인터의 포인터라고 할 수 있다.
연산자와 & 연산자는 서로 반대되는 동작을 하는데 이 두 연산자에 의해 가리키고 끄집어 내오다 보면 동등한 수식이 여러 개 생길 수 있다. 그렇다고 해서 &&i=&pi=ppi라는 등식은 성립하지 않는데 &연산자를 두 번 쓰는 것은 적법하지 않다. 왜냐하면 &연산자의 피연산자는 메모리상의 실제 번지를 점유하고 있는 좌변값(lvalue)이어야 하는데 &i는 i가 저장된 번지를 나타내는 포인터 상수일 뿐 좌변값이 아니기 때문이다.
10-4-나. main 함수의 인수
main도 일종의 함수이므로 인수를 가질 수 있고 리턴값도 가질 수 있다. main 함수의 원형은 다소 복잡한데 다음과 같은 조합이 가능하다.
void(또는 int) main(int argc,char *argv[],char *env[]);
리턴값은 int형이거나 void형 중 하나를 선택할 수 있으며 세 개의 인수를 가지는데 인수는 뒤쪽부터 차례대로 생략 가능하다. 그래서 main 함수의 가능한 원형은 다음 여덟가지나 된다.
① void main(void);
② void main(int argc);
③ void main(int argc,char *argv[]);
④ void main(int argc,char *argv[],char *env[]);
⑤ int main(void);
⑥ int main(int argc);
⑦ int main(int argc,char *argv[]);
⑧ int main(int argc,char *argv[],char *env[]);
main 함수가 리턴하는 값을 탈출 코드(Exit Code)라고 하는데 프로그램이 실행을 마치고 운영체제로 복귀할 때 리턴되는 값이다. 탈출 코드는 보통 사용되지 않고 무시되는데 이 프로그램을 호출한 프로그램(보통 쉘)이 꼭 필요할 경우 탈출 코드를 사용하기도 한다.
argc
운영체제가 이 프로그램을 실행했을 때 전달되는 인수의 개수이다. 함수를 호출할 때 인수를 전달하듯이 운영체제가 프로그램을 호출할 때도 인수를 전달할 수 있다.
argc 인수는 main 함수로 전달된 인수의 개수이다. 첫 번째 인수는 실행 파일명으로 고정되어 있으므로 argc는 항상 1보다 크다. boksa a b 식으로 호출할 경우 argc는 3이 된다.
argv
프로그램으로 전달된 실제 인수값이며 이 값을 읽으면 운영체제로부터 어떤 인수가 전달되었는지 알 수 있다.
boksa file1.txt file2.txt의 경우 boksa, file1.txt, file2.txt 세 개의 문자열이 인수로 전달된다. 그래서 argv는 문자형 포인터를 가리키는 포인터여야 한다.
10-4-라. void 이중포인터
void ** 타입에 대해 연구해 보자. 그다지 실용성이 있는 내용은 아니지만 포인터를 얼마나 잘 이해했는지를 테스트해 보기에 적합한 주제라고 할 수 있다.
'C,C++' 카테고리의 다른 글
[C] C언어 공부하기 12. 문자열 함수 (0) | 2020.01.06 |
---|---|
[C] C언어 공부하기 11. 배열과 포인터 (0) | 2020.01.06 |
[C] C언어 공부하기 9. 배열 (0) | 2020.01.02 |
[C] C언어 공부하기 8. 표준 함수 (0) | 2020.01.02 |
[C] C언어 공부하기 7.지역변수 (0) | 2020.01.02 |