모두의 코드
씹어먹는 C 언어 - <19. main 함수의 인자, 텅 빈 void 형>

작성일 : 2010-08-02 이 글은 75038 번 읽혔습니다.

이번 강좌에서는

  • void 형의 함수, void 형의 포인터에 대한 이해

  • main 함수의 인자에 대한 이해 (argc, argv)

  • 포인터 배열

씹어먹는 C 언어

안녕하세요 여러분. 그동안 잘 지내셨는지요? 책 만드는 것도 어느 정도 진척이 되었고 Latex 도 어느정도 능숙하게 다룰 줄 알아서 꽤 괜찮게 만들 수 는 있었는데 표지가 문제네요. 혹시 '씹어먹는 C 언어' 를 위한 멋진 표지를 만드실 분을 찾고 있으니 혹시 좋은 아이디어가 있으신 분들은 kev0960@gmail.com 으로 꼭 메일을 보내주시기 바랍니다.

리턴값이 없는 함수

때로는 우리가 만드는 함수가 리턴값이 없을 수 도 있습니다. 예를 들어서 어떠한 int 변수에 1 을 더하는 함수를 생각해봅시다.

#include <stdio.h>

void add_one(int* p) {
  (*p) += 1;
}

int main() {
  int a = 1;
  printf("Before : %d \n", a);
  add_one(&a);
  printf("After : %d \n", a);
}

성공적으로 컴파일 하였다면

실행 결과

Before : 1 
After : 2 

와 같이 나옵니다. 위 add_one 함수는 인자로 전달된 포인터가 가리키는 것의 값을 1 증가 시킨 뒤 종료됩니다. 그리고 return 을 수행하는 문장이 없습니다. 왜냐하면

void add_one(int* p) {

위와 같이 void 형으로 선언되어 있기 때문이죠. 영어 사전을 찾아보면 void 는 진공, 공허라는 뜻을 의미합니다. C 언어에 적용해서 생각해보자면 무(無)의 타입을 의미한다고 보실 수 도 있지요. 만일 이 함수가 무언가 리턴을 한다면 그 무언가는 타입이 있어야 되기 때문에 이 void 함수가 가능한 형태는 아무것도 리턴하지 않는 함수 가 됩니다.

따라서 다음과 같은 문장은 모두 틀린 셈입니다.

void a();
int main() {
  int i;
  i = a();

  return 0;
}

void a() {}

즉 함수 a 가 리턴하는 값이 없으므로 i 의 값에 a 의 리턴값을 대입할 수 없습니다. 이 모두 오류로 처리됩니다.

void 형 함수는 많은 곳에서 사용 됩니다. 주로, 리턴을 할 필요가 없는 함수 들의 경우가 대부분이죠. 예를 들어서 두 변수의 값을 교환하는 함수를 생각해봅시다. 아마 여러분은 여태까지 다음과 같이 함수를 만들었을 것입니다.

int swap(int *a, int *b) {
  int temp;

  temp = *a;
  *a = *b;
  *b = temp;

  return 0;
}

하지만 swap 함수는 리턴할 필요가 전혀 없죠. 단순히 두 수의 값만 바꾸면 끝인데 뭐하러 귀찮게 리턴을 하냐 말이죠. 오히려 불필요하게 swap 의 리턴 타입이 있다면 swap 함수를 사용하는 사람 입장에서 이 함수의 리턴값은 무슨 의미지? 를 생각해야 합니다.

void swap(int *a, int *b) {
  int temp;

  temp = *a;
  *a = *b;
  *b = temp;
}

하지만 위와 같이 void 로 함수를 만든다면 그런 걱정은 깔끔하게 날려버릴 수 있습니다. 따라서 이렇게 리턴값이 필요 없는 곳에서 void 함수를 이용하는 것이 좋습니다.

void 형 변수

/* void 형 변수?? */
#include <stdio.h>
int main() {
  void a;

  a = 3;

  return 0;
}

성공적으로 컴파일 하였다면

컴파일 오류

error C2182: 'a' : 'void' 형식을 잘못 사용했습니다.

와 같은 오류 메세지를 보게 됩니다. 우리가 위에서 void 형 함수에 대해 살펴 보았습니다. 그렇다면 void 형의 변수도 정의할 수 있을 것 같은데 사실 이는 오류 입니다. 컴파일러가

int a;

라는 문장을 보게 된다면 컴파일러는 '아, int 형의 변수 a 를 선언하는 구나. 메모리 상에 미리 4 바이트의 공간을 마련해 놓아야지' 라고 생각할 것입니다. 그런데

void a;

를 보게 된다면, '응? 이 변수의 타입은 뭐지?' 라고 생각하게 되죠. 다시 말해 이 변수를 위해서 메모리 상에 얼마나 많은 공간을 설정해 놓아야 하는지 모르게 되는 셈입니다. (참고로 컴파일 때 모든 변수들의 메모리 상의 위치가 결정 되어야 합니다) 따라서 이와 같은 형식은 틀리게 된 셈이죠.

그렇다면 이것은 가능할까요?

/* void 형을 가리키는 포인터 */
#include <stdio.h>
int main() {
  void* a;

  return 0;
}

성공적으로 컴파일 해 본다면 아무런 오류가 뜨지 않는 다는 것을 알 수 있습니다. 왜 그럴까요? 일단, void *a; 의 경우 위에서 지적한 문제는 없다는 것을 알 수 있습니다. 왜냐하면 앞에서 void a; 의 경우 a 의 크기를 정할 수 없기 때문에 메모리 상에 a 를 위해 얼마나 많은 공간을 설정해 놓아야 하는지 모르지만, void *a 의 경우 '포인터' 이기 때문에 메모리 상에 8 바이트 만큼을 지정하게 됩니다. (앞에서 부터 강조해 왔던 이야기 이지만 64 비트 시스템에서 포인터의 크기는 8 바이트 입니다.) 즉, a 에는 어떠한 지점의 메모리의 주소 값이 들어가게 되는 것이지요.

그렇다면 void* a 포인터는 void 형의 변수의 메모리 주소를 가지게 될까요? 물론, 논리를 따지고 보면 맞지만 void 형 변수라는 것은 존재할 수 없기 때문에 void 형 포인터의 존재는 쓸모가 없어 보입니다. 하지만 사실 void 는 타입이 없기 때문에 거꾸로 생각해 보면어떠한 형태의 포인터의 값이라도 담을 수 있게 됩니다. 예를 들면

void *a;
double b = 123.3;

a = &b;

와 같이 말이죠. 다시 말해 a 는 순전히 오직 '주소값의 보관' 역할만 하게 되는 셈입니다.

/* b 의 값을 보려면 */
#include <stdio.h>
int main() {
  void *a;
  double b = 123.3;

  a = &b;

  printf("%lf", *a);
  return 0;
}

성공적으로 컴파일 하였다면

컴파일 오류

error C2100: 간접 참조가 잘못되었습니다.

와 같은 오류를 보게 됩니다. 이 오류가 발생하는 이유 역시 쉽게 알 수 있습니다. 왜냐하면 컴파일러는 *a 가 무엇을 말하는지 알 수 없거든요. 여태까지 *a 를 해석할 때 컴파일러는 a 가 가리키는 것의 타입을 보고 메모리 상에서 a 부터 얼마 만큼 읽어들어야 할 지 결정했는데 void a; 의 경우 메모리 상에서 얼마만큼 읽어들여야 할 지 모르기 때문입니다. 따라서 이는 다음과 같이 수정되어야 합니다.

#include <stdio.h>
int main() {
  void *a;
  double b = 123.3;

  a = &b;

  printf("%lf", *(double *)a);
  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

123.300000

와 같이 잘 출력됨을 알 수 있습니다.

printf("%lf", *(double *)a);

우리는 위 문장에서 형변환 을 이용하였습니다. 즉 단순히 주소값 만을 담고 있는 a 에게 (double *) 를 취함으로써, 컴파일러로 하여금 "이 포인터 a 가 담고 있는 주소값은 double 을 가리키는 주소값이라 생각해" 라고 말한 것이지요. 따라서 (double *)a 부분을 통해 컴파일러는 현재 a 가 가리키고 있는 곳의 주소값을 double 로 생각하게 되어 8 바이트를 읽어들이게 합니다.

void 형 포인터는 단순히 어떤 타입의 포인터의 주소 값도 편리하게 담을 수 있기 때문에 많은 부분에서 활용되고 있습니다. 예를 들어 다음과 같은 역할을 하는 함수를 생각해봅시다

어떠한 특정한 주소값으로 부터 1 바이트 씩 값을 읽어오는 함수

그렇다면 이 함수는에는 인자가 2 개 전달될 텐데, 일단 그 특정한 주소값을 가리키고 있는 포인터와, 얼마나 읽을지 int 형 변수 하나를 받아야 겠지요. 그런데, 인자로 전달될 '특정한 주소값을 가리키고 있는 포인터' 의 타입이 제각각 이라는 것이지요. 예를 들어서 int* 일 수 도 있고 double* 일 수 도 있지요.

따라서 우리는 순전히 주소값 만을 받기 위해서는 void 형 포인터를 사용하는 것이 바람직하다고 볼 수 있습니다. 물론 포인터 간의 형변환을 통해서 처리할 수 있지만 어떠한 형태의 포인터 주소값도 가능하다라는 의미를 살리기 위해서는 void 형 포인터를 이용하는 것이 바람직합니다.

/* 임의의 주소값 p 로 부터 byte 만큼 읽은 함수*/
#include <stdio.h>
int read_char(void *p, int byte);
int main() {
  int arr[1] = {0x12345678};

  printf("%x \n", arr[0]);
  read_char(arr, 4);
}
int read_char(void *p, int byte) {
  do {
    printf("%x \n", *(char *)p);
    byte--;

    p = (char *)p + 1;
  } while (p && byte);

  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

12345678 
78 
56 
34 
12 

read_char 함수를 살펴봅시다. 무언가 여태까지 해온 것 보다 코딩 실력이 업그레이드 된 것 같은데 찬찬히 살펴 보면

do {
    printf("%x \n", *(char *)p);
    byte--;

    p = (char *)p + 1;
  } while (p && byte);

먼저 p = (char *)p + 1; 의 뜻 부터 생각해봅시다. (char *)p 는 '이 p 에 들어있는 값을 char 형 변수의 주소값이라 생각해!' 라는 의미 이지요. 그런데, 거기에 += 1 을 했으므로 포인터의 덧셈이 행해지는데 컴퓨터는 p 를 'char 형 변수의 주소값' 이라 생각하고 있으므로 p 에 1 을 더하게 되면 주소값이 char 의 크기, 즉 1 만큼 늘어납니다.

아무튼 이와 같은 방법으로 p 의 주소값을 계속 1 씩 증가시키는데, 이 때 byte 의 값이 0 이되거나 ((char *)p) 의 값이 0 (즉 NULL 일 때) while 문이 종료됩니다. 제가 do while 을 이용한 이유는 만일 동일한 조건문으로 while 문을 만들게 된다면 처음에 ((char *)p)++ 이 먼저 실행되기 때문에 p 부터 읽지 않고 p + 1 부터 읽게되는 불상사가 발생하기 때문에 이를 막기 위해 do while 문을 이용했습니다.

printf("%x \n", *(char *)p);

p 가 가리키는 주소값에 위치한 데이터 1 바이트 씩 16 진수로 출력하게 됩니다. 따라서 read_char 함수를 호출함을 통해 int 형 배열인 arr 의 원소를 1 바이트씩 읽게 되는 것이죠. 어떤 사람들은 그 결과가 12 34 56 78 순으로 출력해야 한다고 물을 수 있는데, 이는 '엔디안' 에 대한 개념이 없는 것이기 때문에 이 강좌를 잠시 보고 오시기 바랍니다.

간단히 말하자면 우리가 쓰는 대부분의 프로세서는 리틀 엔디안 방식으로 저장하기 때문에 낮은 자리수가 낮은 주소값을 가지게 됩니다. 즉, 낮은 자리수인 78 이 낮은 주소값인 앞쪽에 저장되게 되죠. 따라서 12 34 56 78 순이 아닌 78, 56, 34, 12 순으로 저장되는 것이 맞습니다. (그렇게 따지면 87, 65, 43, 21 순으로 나타나야 되지 않냐고 물을 수 있는데 저장의 단위가 바이트 이므로 한 바이트 내에서는 우리가 생각하는 순서대로 저장됩니다. )

메인 함수의 인자

/* main 함수의 인자라고?? */
#include <stdio.h>
int main(int argc, char **argv) {
  printf("받은 인자의 개수 : %d \n", argc);
  printf("이 프로그램의 경로 : %s \n", argv[0]);

  return 0;
}

성공적으로 컴파일 했다면

아마도 이 강좌를 보고 계신 여러분 중 일부는 위와 같은 메인 함수에 친숙하실 지도 모릅니다.

int main(int argc, char **argv)

보시다시피 main 함수가 인자를 받고 있습니다. 이게 도대체 무슨 일인가요. 다른 함수가 인자를 받는 것은 이해가 잘되는데 main 함수가 인자를 받다니. 도대체 누가 인자를 넣어 주고 있는 것일까요? 바로 운영체제에서 인자를 알아서 넣어주는 것입니다. 바로 위와 같이요.

일단 argcmain 함수가 받은 인자의 수 입니다. 그리고 argvmain 함수가 받은 각각의 인자들을 나타내죠. 프로그램을 실행하면 기본적으로 아무런 인자들을 넣지 않더라도 위와 같은 정보는 들어가게 됩니다. 즉, main 함수는 자신의 실행 경로를 인자로 받게 되죠. 그렇다면 다른 인자들도 넣을 수 있을까요? 한 번 해봅시다.

/* 인자를 가지는 메인 함수 */
#include <stdio.h>
int main(int argc, char **argv) {
  int i;
  printf("받은 인자의 개수 : %d \n", argc);

  for (i = 0; i < argc; i++) {
    printf("이 프로그램이 받은 인자 : %s \n", argv[i]);
  }

  return 0;
}

성공적으로 컴파일 했다면

일단 우리는 프로그램을 임의의 개수의 인자를 받아 받은 인자들을 모두 출력하게 하였습니다. 그렇다면 프로그램에 직접 인자를 넣어 봅시다.

  • 윈도우즈 XP 의 경우 : 시작 -> 실행 -> cmd

  • 윈도우즈 Vista, 7 의 경우 : 시작 -> 하단에 '프로그램 밑 파일 검색' 에 cmd 라고 친다.

그렇다면 아래와 같은 모습을 보실 수 있습니다.

이것은 '명령 프롬포트' 라고 부르는 것인데 기존의 MS-DOS 와 유사합니다. (그러나 본질적으로 다릅니다) 우리는 여기서 윈도우즈 처럼 파일을 클릭하여 실행하는 것과는 달리 직접 명령어를 침으로써 파일을 실행시켜야 합니다. 그러기 위해선 우리가 원하는 파일이 어디있는지 알아야겠죠.

먼저 위와 같이 화면에 cd C:\\ 을 씁니다. 이 명령어의 의미는 'C:\' 라는 경로로 들어가라 입니다. 즉 'cd' 의 의미는 지정하는 경로로 들어가게 해주죠. 참고로 여기서도 역시 \ 하나만 치면 다른 의미로 해석되기 때문에 \ 하나를 나타내기 위해서는 \ 를 두번 써야 합니다.

이제 화면에 dir 을 쳐봅시다. 'dir' 의 의미는 이 경로에 들어있는 폴더와 파일들을 보여주라는 의미 입니다. 위 사진에서는 사생활보호 차원을 위해 Program Files 빼고 이름을 모두 가렸습니다. 이 때, 어떤 것은 왼쪽에 <DIR> 이라고 나오고 어떤 것은 없는 것을 볼 수 있는데, <DIR> 이란 것은 '폴더' 와 같은 뜻으로 파일이 아니라는 것입니다. 반면에 <DIR> 이 없는 것은 파일이 되겠지요. 아마 프로그램을 C 드라이브에 깔았기 때문에 아마 모든 파일은 동일한 경로에 있을 것입니다.

위와 같은 방식으로 우리의 파일을 찾는 일만 남았습니다.

우리가 원하는 파일은 아마 C:\Users\Lee\Documents\Visual Studio 2008 에 있습니다. 이를 cd 명령어로 다 치면 됩니다. 이 때, 중간에 띄어쓰기가 있으므로 큰 따옴표로 묶어주어야 합니다. 즉 cd "C:\\Users\\Lee\Documents\\Visual Studio 2008" 처럼 말이죠. 그렇지 않고 cd C:\\Users\\Lee\Documents\\Visual Studio 2008 로 쓴다면 컴퓨터는 cd C:\\Users\\Lee\Documents\\Visual 로 인식합니다.

이제 Project 폴더로 들어가보겠습니다. 단순히 cd Projects 라고 치면 됩니다.

오. 저의 프로젝트인 'teach' 가 보이네요. 여러분과 저와의 프로젝트 이름이 다를 수 있으니 각기 맞는 프로젝트로 들어가시면 됩니다. 여기서도 물론 폴더 이름에 띄어쓰기가 있다면 큰따옴표로 묶어주는 것을 잊지 마세요.

이제, 파일들을 쭉 보면 Debug 라는 폴더와 teach 라는 폴더가 있는데, teach 폴더에는 우리의 소스 코드가, Debug 폴더에는 만들어진 실행 파일이 있습니다. 그렇다면 우리는 어디로 가야 할까요? 네, Debug 로 갑시다.

우와 그렇다면 위와 같이 teach.exe 를 보실 수 있습니다. 이제 teach.exe 를 침으로써 위 프로그램을 실행할 수 있습니다.

우왕. 잘 실행되는 군요. 일단 신기한 점은 이 프로그램이 받은 인자가 더이상 그 프로그램의 경로가 들어가지 않고 teach.exe 가 들어갔습니다. 맞습니다. 우리가 teach.exe 를 침으로써 실행한 순간 이 프로그램의 첫번째 인자는 teach.exe 가 됩니다.

만일 우리가 이 프로그램을 "C:\\Users\\Lee\\Documents\\Visual Studio 2008\\Projects\\teach\\Debug\\teach.exe" 라고 쳐서 실행하였다면 인자가 C:\\Users\\Lee\\Documents\\Visual Studio 2008\\Projects\\teach\\Debug\\teach.exe 가 되겠지요.

그렇다면 다른 인자들을 넣어봅시다. 이는 간단합니다. 프로그램 이름 뒤에 다른 것들을 써주면 되죠. 예를 들어

teach.exe abc def 라고 쓰게 된다면 teach.exe 가 첫번째 인자, abc 가 두번째 인자, def 가 세번재 인자가 되고 받은 인자의 수는 3 이 되지요. 어때요. 쉽지요?

그런데 여태까지 배운 내용을 잘 숙지하신 분이라면 다음과 같은 질문을 하실 수 있습니다.

main 함수의 두번째 인자 말이에요, char ** 인데 제 기억에 이차원 배열을 전달하기 위해서는 char (*argv)[5] 와 같이 반드시 크기를 명시해 주어야 하는데 여기서는 단순히 char** 로 해놓고 어떻게 그리 잘 작동하는지요?

그 이유는 간단합니다. char**(char *) 형 배열을 가리키는 포인터 이지요. 즉, 포인터의 배열 입니다. (배열 포인터가 절대로 아닙니다) int arr[4] 라는 배열을 가리키는 포인터가 int * 형인 것 (여기서는 arr 이겠네요) 처럼 char *arr[5]; 를 가리키는 포인터의 형은 char** 이 되겠지요.

즉, 다음과 같은 꼴이 되겠지요.

argv 는 포인터들의 배열을 가리키고 있고, 그 포인터 배열에서의 각각의 원소, 즉 포인터들은 인자로 전달된 문자열들을 가리키고 있습니다. 이 때, 이 문자열들은 메모리의 다른 공간에 보관되어 있겠죠.

따라서 우리는 argv[i] 를 통해 특정한 인자의 문자열에 저장된 주소값을 나타낼 수 있게 됩니다.

그럼 이상으로 이번 강좌를 마치도록 하겠습니다. 이번 강좌는 다음에 배울 동적 메모리 할당에 밑바탕이 되는 정보 이니 절대로 잊지 마시기 바랍니다.

생각해 보기

문제 1

메인함수의 인자를 활용한 계산기를 만들어보세요. 예를 들어서

calc.exe 5 + 10

을 치면 15 가 나오게 하면 되지요.

이 때, 5, +, 10 은 모두 다른 인자로 봐야하겠죠. 기초적인 단계 이므로 연산자는 하나만 써도 된다고 합시다.참고로 인자는 모두 문자열 형태로 오기 때문에 문자열로 된 수를 int 형으로 바꾸는 작업이 필요할 것입니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C 언어 - <19. main 함수의 인자, 텅 빈 void 형>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 68 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..