C++/C++ 기초 플러스 6판(Book)

[C++] C++의 프로그래밍 모듈

suppresswisely 2025. 3. 4. 18:38

함수 정의

int bigger(int a, int b)
{
    if (a > b)
    	return a;
    else
       return b;
}

 

일반적으로 여러 다양한 리턴 구문이 하나의 함수 안에 존재하는 것은 혼동을 가져올 우려가 있기 때문에, 어떤 컴파일러는 경고가 발생하기도 한다.

 

함수 원형은 왜 필요한가?

함수 원형은 컴파일러에게 함수의 인터페이스를 알려준다.

double volume = cube(side);

 

첫째, 함수 원형은 cube() 가 하나의 double형 매개변수를 가진다는 사실을 컴파일러에게 알려 준다. 프로그램이 cube()에 매개변수를 제공하는데 실패하면, 컴파일러는 함수 원형에 근거하여 에러를 검출한다.

 

둘째, cube() 함수는 계산을 끝냈을 때 리턴값을 (CPU의 레지스터나 메모리의) 어떤 지정된 우치에 넣는다. 그러면 호출한 함수가 그 위치에 값을 꺼내온다. cube()가 double형이라는 사실을 함수 원형이 컴파일러에게 알려 주므로, 컴파일러는 그 위치에서 몇 바이트를 꺼내고, 어떻게 처리해야 하는지 알고 있다.

 

컴파일러가 함수 원형을 요구하는 이유를 아직도 잘 모르겠는가? 함수 원형을 제공하는 대신에, 컴파일러가 파일의 내부를 조사하여 그 함수가 어떻게 정의되었는지 직접 확인하면 되지 않을까? 그러나 이러한 접근 방식은 매우 비효율적이다. 왜냐하면 컴파일러가 그 함수를 찾기 위하여 파일의 내부를 뒤지는 동안 main()함수의 컴파일을 잠시 보류해야 한다.

 

함수 원형이 사용자를 위해 하는일

지금까지 함수 원형이 컴파일러를 위해 하는 일을 살펴보았다. 그렇다면 함수 원형은 사용자를 위해서는 어떤 일을 할까? 함수 원형을 사용하면 프로그램 에러를 만들 확률이 많이 줄어든다. 함수 원형은 특별히 사용자에게 다음과 같은 것을 보장한다.

  • 컴파일러가 함수의 리턴값을 바르게 처리한다.
  • 사용자가 정확한 개수의 매개변수를 사용했는지 컴파일러가 검사한다.
  • 사용자가 정확한 데이터형의 매개변수를 사용했는지 컴파일러가 검사한다. 사용자가 정확한 데이터형을 사용하지 않았다면, 컴파일러가 정확한 데이터형으로 변환한다.

함수 원형 비교는 컴파일 시 이루어진다. 이것을 정적 데이터형 검사라 한다. 정적 데이터형 검사는 실행 시 잡아내기 어려운 에러들을 컴파일 시에 잡아낸다.

 

함수 매개변수와 값으로 전달하기

double volume = cube(side);

double cube(double x)

 

side에 5를 대입하여 이 함수가 호출되면 x라는 새로운 double형이 변수가 생성되고, 거기에 5라는 값이 대입된다. cube()는 원본 데이터가 아닌 side의 복사본을 가지고 작업하기 때문에, 결과적으로 cube()에서 일어나는 사건이 main()에 있는 원본 데이터에는 영향을 주지 않는다. 원본 데이터가 보호되는 이러한 예를 곧 보게 될 것이다. 이렇게 전달되는 값을 넘겨받는 데 쓰는 변수를 형식 매개변수(formal parameter)라 한다. 또한 함수에 전달되는 값을 실제 매개변수(actual argument)라 한다. 이문제를 좀 더 간단히 설명하면, C++ 표준은 실제 매개변수를 argument라는 단어로 나타내고, 형식 매개변수를 parameter라는 단어로 나타낸다. 따라서 이 용어를 사용한다면, 함수에 매개변수를 전달하는 것은 argumentparameter에 대입하는 것이다.

 

함수 안에서 선언된 모든 변수들은 그 함수 안에서만 활동한다. 이러한 변수들은 함수 안에서만 활동하기 때문에 지역 변수(local variable)라고 부른다. 앞에서도 언급했듯이, 이러한 접근 방법은 데이터의 무결성(data integrity)을 높여 준다.

 

배열을 매개변수로 사용하는 것의 의미

arr배열이 arr[0]와 *arr가 같이 배열 이름과 포인터 간의 이러한 대응 관계는 과연 좋은 것인가? 그렇다. 배열의 주소를 매개변수로 사용하는 것은 전체 배열을 복사하는 것보다 시간과 메모리를 절약한다. 복사본을 사용하는 것은 배열이 클 경우에는 부담이 된다. 복사본을 가지고 작업하면 메모리를 더 많이 요구할 뿐만 아니라 큰 데이터 블록을 복사해야 하므로 시간이 오래 걸린다. 반면에 원본을 대상으로 작업하면 부주의에 의해 데이터가 손상될 위함이 있다. 클래식 C에서 이것은 골치 아픈 문제였다. 그러나 ANSI C와 C++의 const 제한자가 이 문제에 대한 해결책을 제공한다. 곧 그 예를 보게 될 것이다.

 

배열의 내용 출력과 const로 보호하기

출력이 배열의 원본을 변경시키지 않는다는 보증을 해야한다. 함수의 목적이 데이터를 변경하는 것이 아니라면, 데이터가 변경되지 않도록 보호해야 한다. 일반 변수의 경우에는 C++가 그것을 함수에 값으로 전달하고, 함수는 복사본을 가지고 작업하기 때문에 이 문제가 자동으로 해결된다. 그러나 배열을 사용하는 함수는 원본을 대상으로 작업한다.

 

배열의 값이 변경되지 않도록 하려면 const키워드를 형식 매개변수를 선언할 때 사용할 수 있다.

void show_array(const double ar[], int n);

 

이 선언은 포인터 ar가 상수 데이터를 지시하고 있다는 것을 의미하며, ar를 사용해서는 그 데이터를 변경할 수 없다는 것을 뜻한다. ar[0]과 같이 그 값을 사용할수는 있지만 변경할 수는 없다. 이것은 원본 배열이 반드시 상수이여야 한다는 의미는 아니다. 다만 show_array() 함수가 ar를 사용하여 그 데이터를 변경할 수 없다는 뜻이다. 따라서 show_array()는 그 배열을 읽기 전용 데이터로 취급한다.

 

포인터와 const

포인터에 const를 사용하는 것은 난해한 구성이 있다. const라는 키워드는 포인터에 두 가지 방법으로 사용된다.

첫 번째 방법은 상수 객체를 지시하는 포인터를 만드는 것이다. 상수 객체를 지시하는 포인터를 사용하여 그 포인터가 지시하는 값을 변경할 수 없다.

두 번째 방법은 포인터 자신을 상수로 만드는 것이다. 상수 포인터를 사용하여 그 포인터가 지시하는 장소를 변경할 수 없다. 이에 대해 좀 더 자세히 알아보자.

 

먼저 상수를 지시하는 pt라는 포인터를 선언해 보자

int age = 39;
const int * pt = &age;

 

이 선언은 포인터가 pt가 const int를 지시하고 있음을 말해 준다. 그러므로 포인터인 pt를 사용하여 그 값을 변경할 수 없다. 다시 말해서, *pt가 const이므로 변경할 수 없다.

 

이제 다소 난해한 문제를 구민해 보자, pt에 대한이 선언은 그것이 지시하는 값이 실제로 상수라는 것을 의미하지는 않는다. 단지 pt가 관계하는 한에서만 그 값이 상수라는 것을 의미한다. 예를 들어, pt는 age를 지시하고 있지만, age는 const가 아니다. 따라서 age 변수를 직접 사용하여 age의 값을 변경할 수 있다. 그러나 pt를 사용해서는 age의 값을 변경할 수 없다.

 

지금까지 우리는 일반 변수의 주소를 일반 포인터에 대입했다. 그러나 지금은 일반 변수의 주소를 const를 지시하는 포인터에 대입했다. 아직도 다른 두 가지 가능성이 남아 있다.

하나는 const 변수의 주소를 const를 지시하는 포인터에 대입하는 것이고,

다른 하나는 const 변수의 주소를 일반 포인터에 대입하는 것이다. 

이들 두가지가 모두 가능할까? 결론부터 말하면, 첫번째의 경우는 가능하지만 두번째 경우는 불가능 하다.

const float g_earth = 9.80;
const float * pe = &g_earth; // 사용할 수 있다.

const float g_moon = 1.63;
float * pm = &g_moon; // 사용할 수 없다.

 

첫 번째 경우에는 g_earth나 pe를 사용하여 9.80이라는 값을 변경시킬 수 없다.

두 번째 경우에는 간단한 이유로 C++가 이것을 허락하지 않는다. 만약 g_moon의 주소를 pm에 대입할 수 있다면, pm을 사용하여 g_moon의 값을 변경할 수 있게 된다. 그것은 g_moon이 const라는 것을 무의미하게 만든다. 그렇기 때문에 C++는 const 변수의 주소를 const가 이난 일반 포인터에 대입하는 것을 금지한다.(그래도 필사적으로 꼭 그렇게 하기를 원한다면, 데이터형 변환을 사용하여 이 제한을 무시할 수 있다.)

 

const와 const가 아닌 것을 이런 식으로 섞어서 사용하는 포인터 대입은 두 다리 건너는 간접 지시인 경우에는 더 이상 안전하지 않다. const와 const가 아닌것을 섞어서 사용하는 것이 허용된다면 다음과 같은 것도 가능하게 되기 때문에 안된다.

const int **pp2;
int *p1;
const int n =13;
pp2 = &p1; // 허용되지 않지만 허용된다고 가정하면
*pp2 = &n; // 둘 다 const인데 p1이 n을 지시하게 만든다.
*p1 = 10; // const n을 변경하게 만든다.

 

앞의 코드는 const가 아닌 주소(&p1)를 const 포인터(pp2)에 대입한다. 그것은 p1을 사용하여 const 데이터를 변경하도록 허용한다. 그러므로 const가 아닌 주소나 포인터를 const 포인터에 대입할 수 있다는 규칙은 한 다리 건너는 간접 지시인 경우에만 유효하다.

 

const 데이터들은 원소를 가지는 다음과 같은 배열이 있다고 가정하자.

const int months[4] = {32, 28, 31, 30};

 

상수 배열 주소를 대입할 수 없도록 금지하는 것은, 상수가 아닌 형식 매개변수를 사용하여 배열의 이름을 전달 매개변수로 함수에 전달할 수 없다는 것을 의미한다.

이 함수 호출은 const 포인터인 months를 const가 아닌 포인터 arr에 대입하려고 시도한다. 그러나 컴파일러는 이러한 호출을 허용하지 않는다.

 

또 하나의 난해한 문제로, 다음과 같은 선언을 고려해 보자.

int age = 39;
const int * pt = &age;

 

두 번째 선언에 있는 const는, pt가 지시하는 39라는 값을 변경하지 못하도록 막는다. 그러나 pt 자신의 값을 변경하는 것은 막지 않는다. 즉, 가등과 같이 pt에 새로운 주소를 대입할 수 있다.

int sage = 80;
pt = &sage;

 

그러나 pt 를 사용하여 그것이 지시하는 값을 변경할 수 없다.

const를 사용하는 또 하나의 방법은 포인터 자신의 값을 변경하지 못하게 막는 것 이다.

int sloth = 3;
const int * ps = &sloth; // const int를 지시하는 포인터
int * const finger = &sloth; // int를 지사하는 const 포인터

 

마지막 선언은 const 키워드가 놓이는 위치만 바꿨다. 이러한 형태의 선언은 finger가 sloth만을 지시하도록 제한한다. 그러나 finger를 이용하여 sloth의 값을 변경할 수는 있다. 그리고 가운데에 있는 선언은 ps를 사용하여 sloth의 값을 변경할 수는 없다. 그러나ps가 다른 위치를 가리킬 수는 있다. 쉽게 말해서, finger와 *ps는 const이고, *fnger와 ps는 const가 아니다.

 

*책과 무관한 이야기

이해하는 방법을 제시하자면, const int* ptr을 하면 *ptr은 상수가 된다. 왜냐면 *ptr에 상수를 뜻하는 *ptr에 const를 주었기 때문이다. 반대로int* const ptr이면 ptr이 상수가 된다.

혹시 int*가 정수형 포인터라는 뜻이니, const int* ptr는 상수 포인터/변수이고 int* const ptr는 정수형 포인터/상수로 보는 것이 맞지 않나요? 생각한다면, 어다르고 아다른 것이니 그냥 넘어가면 된다.

 

함수와 2차원 배열

다음과 같은 코드를 가지고 시작한다고 가정하자.

int data[3][4] = {{1,2,3,4}, { 9, 8, 7, 6}, {2,4,6,8}};
int total = sum(data, 3);

 

sum() 함수의 원형은 과연 어떤 모습일까? data의 데이터형은 4개의 int값을 가진 배열을 지시하는 포인터이다. 그래서 함수 원형은 가음과 같이 될 것 이다.

int sum(int (*ar2) [4], int size);

 

괄호가 반드시 필요하다. 괄호를 생략하고 다음과 같이 선언하면

int *ar2 [4]

 

4개의 int값을 가진 배열을 지시하는 포인터를 선언하는 것이 아니라, int값을 지시하는 포인터 4개를 가지고 있는 배열을 선언하는 것이 된다. 게다가 배열은 함수의 매개변수로 사용할 수 없다. 첫 번째 원형과 정확히 같은 것을 의미하는 또 다른 형식이 있다. 이것이 더 읽기 쉽다.

int sum(int ar2[] [4], int size);

 

구조체 주소의 전달

구조체 전체를 함수에 전달하는 대신에, 구조체의 주소만 함수에 전달하여 시간과 공간을 절약하고 싶다고 가정해 보자. 이렇게 하면 구조체를 지시하는 포인터를 사용하도록 함수를 다시 작성해야 한다. 

 

다중 재귀 호출

재귀 호출운 하나의 작업을 서로 비슷한 두개의 작은 작업으로 반복적으로 분할해가면서 처리해야 하는 상황에서 특별히 유용하다. 예를 들면, 눈금자를 그리는 데 이것을 적용할 수 있다. 두 개의 끝을 먼저 표시한 후 그들의 중간 지점을 찾아 눈금을 표시한다. 동일한 절차를 눈금자의 왼쪽 절반에 대해 수행한다. 그러고 나서 눈금자의 오른쪽 절반에 대해서도 같은 절차를 수행한다. 눈금 간격을 더욱 세분하려면 현재의 눈금 구획에 대해 동일한 절차를 다시 수행한다. 이러한 재귀적인 접근을 정복 분할(divide-and-conquer) 전략이라고 한다.

 

함수 포인터의 기초

예제를 통해 이 과정을 명확히 이해하도록 하자. 주어진 행 수만큼 프로그램 코드를 작성하는 데 걸리는 시간을 평가하는 estimate()라는 함수를 설계해야한다고 가정하자. 그러기 위해서는 프로그래머 각자가 원하는 특별한 알고리즘 함수의 주소 estimate() 함수에 전달해야 한다. 이것을 구현하려면 다음과 같은 절차를 따라야 한다.

  • 함수의 주소를 얻는다.
  • 함수를 지시하는 포인터를 선언한다.
  • 함수를 지시하는 포인터를 사용하여 그 함수를 호출한다.

함수 주소 얻기

함수의 주소를 얻는 것은 간단하다. 뒤에 붙는 괄호를 빼주고 함수 이름만 사용하면 된다. 즉, think()가 함수라면 think는 그 함수의 주소이다.

 

함수를 지시하는 포인터의 선언

어떤 데이터형을 지시하는 포인터를 선언하려면, 그 포인터가 명확하게 어떤 데이터형을 지시하는지 선언에서 정확하게 지정해야 한다. 마찬가지로, 함수를 지시하는 포인터를 선언할 때에도, 그 포인터가 지시하는 함수의 데이터형을 지정해야한다. 이것은 포인터 선언이 함수의 리턴형과 함수의 시그내처(매개변수 리스트)를 알려 주어야 한다는 것을 의미한다. 즉, 함수 원형이 그 함수에 대해 제공하는 것과 동일한 정보를 선언이 제공해야 한다. 

double (*pf)(int); // pf는 double을 리턴하는 함수를 지시하는 포인터
double *pf(int); // pf()는 double형을 지시하는 포인터를 리턴하는 함수

 

일반적으로, 어떤 함수를 지시하는 포인터를 선언하는 절차는 다음과 같다.

함수의 원형을 먼저 작성한 다음, 함수 이름을(*pf)형태의 표현식으로 대체한다. 이 경우에, pf는 그 데이터형의 함수를 지시하는 포인터이다.

 

포인터를 사용하여 함수 불러내기

(*pf)가 함수 이름과 같은 역할을 한다는 것을 기억하라. 그러므로 (*pf)를 함수 이름 대신에 사용하면 된다.

int code;

estimate(code, gildong);

double gildong(int lns)
{
    return 0.05 * lns;
{

void estimate(int lines, double (*pf)(int))
{
    cout << lines << "행을 작성하는데";
    cout << (*pf)(lines) << "사간이 걸립니다." << endl;
}

 

함수 포인터의 변형

함수 포인터는 표기법이 난해하다는 문제점을 가지고 있다. 함수 포인터의 문제와 해결할 수 있는 방법에 대해 예를 들어 살펴보자. 다음은 동일한 특징과 리턴값을 가지는 함수 원형들이다.

const double * f1(const double ar[], int n);
const double * f2(const double [], int;
const double * f3(const double *, int);

 

보기에는 다르게 보일 수 있지만, 모두 같은 함수이다. 다음 이 세 개의 함수를 가리킬 수 있는 포인터를 선언한다고 생각해 보자. 기술적으로, pa가 포인터라면 함수 이름을 (*pa)로 대체하고 함수 원형을 작성해야 한다.

const double * (*p1) (const double *, int); // 함수 원형
const double * (*p1) (const double *, int) = f1; // 함수 초기화
auto p2 = f2; // C++11 자동 형 변환

 

함수 포인터를 호출 할때에는 다음을 지켜야한다.

 

double av[3] = {11.2, 15.6, 22.9};

// 주소값
(*p1)(av,3) 
p2(av,3)

// 실제 값
*(*p1)(av,3) 
*p2(av,3)

 

(*p1)(av,3)과 p2(av,3)은 모두 av와 3을 매개변수로 하는 함수(f1()과 f2())를 호출하고 있다. 그러므로, 이 두 함수의 리턴 값이 출력되며, 리턴 값은 const double *형이 된다. (double 값의 주소이다.) 실제 값은 그 주소에 저장되어 있기 때문에, *연산자를 이용하여 * (*p1)(av,3), *p2(av,3)과 같이 표현해야 한다.

 

최종적으로 활용할 수 있는 방법은 다음과 같다.

// 훔수 포인터 배열 선언
const double * (*pa[3])(const double *, int) = {f1, f2, f3};

// auto 대입 선언 초기화는 불가
auto pd = pa;

// 초기화 주소 값 얻기
const double * px = pa[0](av, 3);
const double * py = (*pb[1])(av, 3);

// 초기화 실제 값 얻기
double x = *pa[0](av, 3);
double y = *(*pd[1])(av, 3);

// 배열 전체를 가리키는 포인터
auto pc = &pa;
const double * (*(*pc [3]))(const double *, int) = &pa;

 

*책과 무관한 이야기

함수 포인터도 머리 아프지만 함수 포인터 배열로 만들고 그 포인터 전체를 가리키기 위해 단일 값으로 초기화 한다는 일은 생각만 해도 복잡하다. 이를 간단하게 보기 위해서 표로 만들었지만 여전히 복잡하다. 그래도 *ptr은 실제 값 ptr은 주소 값이라는 것만 기억하면 어느정도 볼만하다.

종류 주소 값 (i는 변수) 실제 값 (i는 변수)
함수 포인터 (*p1)(av, 3) *(*p1)(av, 3)
함수 포인터를 원소로 가지는 배열 p1[i](av, 3) *p1[i](av, 3)
함수 포인터를 가리키는 포인터 (*p1) [i] (av, 3) *(*p1) [i] (av, 3)
포인터를 원소로 가지는 배열을 가리키는 포인터 (*(*p1) [i]) (av, 3) *(*(*p1) [i]) (av, 3)

 

auto의 진가

C++11의 목표 중의 하나가 C++을 보다 쉽게 사용하고, 프로그래머가 세부적인 것은 신경을 덜 쓰면서도 설계에 더 딥중할 수 있도록 하는 것이다. 자동 형 변환은 컴파일러의 임무에 대한 철학적 변화를 반영한 것이다. C++98에서 컴파일러는 프로그래머가 잘못 하였을 때 알려 줘야 하는 지식에 중점을 두었다. C++11에서는 이것을 최소한으로 가져가면서, 프로그래머가 올바른 선언을 하는 데 도움을 줄 수 있는 지식에 중점을 두고 있다.

 

여기에 잠재적 결점이 있다. 자동 형 변환은 초기화하는 형과 변수의 형을 매치시키는 것을 보장한다. 그러나, 초기화 시에 잘못된 형을 제공할 가능성이 여전히 존재한다.

 

typedef를 이용한 단순화

C++은 선언을 단순하게 하기 위해 auto외의 다른 방법을 제공한다. typedef 키워드는 데이터 형에 가명을 붙일 수 있다.

typedef const double *(*p_fun)(const double *, int);
p_fun p1 = f1;

 

함수형 포인터 형을 p_fun이라는 가명으로 만들 수 있다.