1. 동적 메모리 할당

동적 메모리 할당(Dynamic Memory Allocation)

프로그램 실행 중 필요한 메모리를 확보하고, 사용이 끝나면 해제하는 방식이다. 일반적으로 힙 영역을 활용한다

프로그램을 작성할 때 필요한 데이터의 개수를 알 수 없는 경우가 있다

예를 들어 학생 정보를 저장하려는데 학생 수를 실행 중에 입력받는다면, 입력한 수에 맞추어 저장 공간을 확보할 수 있어야 한다

상황필요한 처리
실행 전에 원소 수가 정해짐고정 크기 배열 사용 가능
실행 중 원소 수가 결정됨실행 중 크기에 맞는 저장 공간 확보
사용하던 데이터가 더 이상 필요하지 않음할당한 메모리 해제

동적 메모리 할당을 사용하면 필요한 시점에 공간을 확보하고, 사용이 끝난 공간을 다시 활용할 수 있도록 반환할 수 있다

1.1 고정 크기 배열의 한계

일반적인 고정 크기 배열은 배열 크기가 컴파일 시 결정되어야 한다

int numbers[20];

이 배열에는 int형 원소 20개를 저장할 수 있다. 필요한 원소 수가 20개보다 많아지더라도 배열 자체의 크기가 자동으로 늘어나지 않는다

numbers[19] = 10; // 마지막 원소
 
numbers[20] = 20; // 배열 범위를 벗어난 접근

반대로 매우 큰 배열을 미리 만들면, 실제로는 사용하지 않는 저장 공간까지 확보할 수 있다

1.1.1 실행 중 입력한 크기 사용

다음 형태는 표준 C++의 일반적인 배열 선언으로 허용되지 않는다

int n;
std::cin >> n;
 
int numbers[n]; // 표준 C++에서는 허용되지 않음

일부 컴파일러가 확장 기능으로 허용하더라도 표준 C++ 문법과는 구분해야 한다. C에서는 표준 버전과 구현에 따라 가변 길이 배열 지원 여부가 다르다

C++에서는 동적 배열을 생성하여 실행 중 결정한 크기를 사용할 수 있다

int* numbers = new int[n];

이때 n은 적절한 값인지 확인한 뒤 사용해야 한다. new[]의 자세한 문법과 해제 방법은 3단원에서 다룬다

1.2 동적 할당의 기본 과정

동적 메모리를 직접 관리하는 과정은 다음과 같다

  1. 필요한 크기의 메모리를 할당한다
  2. 반환된 주소를 포인터에 저장한다
  3. 데이터를 초기화한 뒤 사용한다
  4. 사용이 끝나면 메모리를 해제한다
단계핵심 내용
할당필요한 저장 공간 확보
주소 저장할당된 공간에 접근할 포인터 유지
초기화와 사용값을 저장하고 읽거나 변경
해제더 이상 필요하지 않은 공간 반환

메모리를 할당하는 것과 값을 초기화하는 것은 별개이다. 할당 방식에 따라 초기값이 지정되지 않을 수 있으므로, 값을 읽기 전에 초기화 여부를 확인해야 한다

1.3 포인터와 동적으로 할당한 객체

포인터 변수와 동적으로 할당한 객체는 서로 다른 대상이다

void func()
{
    int* ptr = new int(10);
 
    delete ptr;
}
대상역할일반적인 저장 위치
ptr동적 객체의 주소를 저장하는 지역 변수스택 또는 레지스터
new int(10)으로 생성한 객체정수 10을 저장힙

delete ptr;는 ptr이 가리키는 동적 객체를 제거하고 저장 공간을 해제한다. 지역 포인터 변수 ptr 자체를 제거하는 문장은 아니다

일반적인 자동 지역 변수는 블록 실행이 끝나면 수명이 끝나지만, 직접 동적 할당한 객체는 해당 블록이 끝났다는 이유만으로 자동 해제되지 않는다

포인터와 동적 객체의 수명 구분

지역 포인터 변수의 수명이 끝나도 그 포인터가 가리키던 동적 객체는 남을 수 있다. 직접 할당한 메모리는 누가 언제 해제할지 관리해야 한다

1.4 메모리 누수

메모리 누수(Memory Leak)

더 이상 필요하지 않은 동적 메모리를 해제하지 못해 사용 중인 상태로 남기는 문제이다. 해당 메모리에 접근할 유일한 포인터를 잃어버리는 경우가 대표적이다

void func()
{
    int* ptr = new int(10);
    // 해제 없이 함수 종료
}

함수가 끝나면 지역 변수 ptr은 사라지지만, 동적으로 생성한 정수 객체는 자동으로 해제되지 않는다. 다른 곳에 주소를 보관하지 않았다면 해당 공간에 접근할 방법도 잃게 된다

이러한 할당이 반복되면 프로그램의 메모리 사용량이 계속 증가할 수 있다

1.5 실행 중 크기 결정과 크기 변경의 차이

실행 중 배열 크기를 결정할 수 있다는 것과 생성된 배열의 크기가 자동으로 변한다는 것은 다르다

int n = 3;
int* numbers = new int[n];
 
n = 5;

n을 5로 변경해도 이미 생성한 배열의 원소 수는 여전히 3개이다

작업기존 동적 배열에 미치는 영향
new int[n] 실행그 시점의 n에 맞는 배열 생성
이후 n 변경기존 배열 크기는 바뀌지 않음
더 큰 배열 필요새 공간 확보와 데이터 이전 등의 처리 필요

크기를 자주 변경해야 하는 C++ 프로그램에서는 std::vector처럼 저장 공간을 관리하는 컨테이너를 사용할 수도 있다






2. C의 malloc()과 free()

2.1 malloc()

malloc()

실행 중 지정한 바이트 수만큼 메모리를 할당하고, 성공하면 해당 공간의 시작 주소를 반환하는 함수이다. 할당에 실패하면 널 포인터를 반환한다

C에서는 <stdlib.h>를 포함하여 사용한다

#include <stdlib.h>

기본적인 사용 형태는 다음과 같다

포인터 = malloc(필요한_바이트_수);

정수 하나를 저장할 공간은 다음과 같이 확보한다

int* ptr = malloc(sizeof(int));
표현의미
sizeof(int)정수 하나를 저장하는 데 필요한 바이트 수
malloc(sizeof(int))해당 크기의 메모리 할당 요청
ptr반환된 주소를 저장하는 포인터

malloc()은 전달받은 바이트 수를 기준으로 공간을 확보한다. 정수 몇 개를 저장하려는지는 직접 계산하여 전달해야 한다

2.2 배열을 위한 메모리 할당

정수 n개를 저장하려면 원소 하나의 크기에 원소 수를 곱한다

int n = 3;
int* arr = malloc(sizeof(int) * n);

필요한 크기는 다음과 같다

int가 4바이트이고 n이 3이라면 총 12바이트를 요청한다

할당에 성공했다면 배열처럼 인덱스로 접근할 수 있다

arr[0] = 10;
arr[1] = 20;
arr[2] = 30;
표현접근하는 위치
arr[0]첫 번째 정수
arr[1]두 번째 정수
arr[2]세 번째 정수

포인터가 가리키는 자료형의 크기를 이용하여 다음과 같이 작성할 수도 있다

int* arr = malloc(sizeof *arr * n);

여기서 sizeof *arr는 int의 크기이다. 이 식에서는 *arr의 값을 실제로 읽지 않는다

2.3 할당과 초기화의 구분

malloc()은 공간을 확보하지만, 그 공간에 저장할 정수 값을 초기화하지는 않는다

int* ptr = malloc(sizeof(int));

할당에 성공한 뒤에도 값을 읽기 전에 대입해야 한다

*ptr = 10;

배열도 각 원소에 값을 저장한 뒤 사용한다

for (int i = 0; i < n; ++i)
{
    arr[i] = i;
}
작업수행 여부
저장 공간 확보malloc()이 수행
정수 값을 자동으로 0으로 초기화수행하지 않음
원소에 필요한 값 저장별도의 처리 필요

2.4 할당 실패 확인

malloc()이 항상 성공하는 것은 아니다. 반환된 포인터가 널 포인터인지 확인한 뒤 사용한다

int* ptr = malloc(sizeof(int));
 
if (ptr == NULL)
{
    /* 할당 실패 처리 */
    return 1;
}
 
*ptr = 10;

배열을 할당할 때는 원소 수가 적절한지도 확인해야 한다. 지나치게 큰 원소 수는 할당 실패뿐 아니라 바이트 수 계산의 범위를 넘는 문제를 일으킬 수 있다

2.5 free()

free()

동적으로 할당한 메모리 블록을 해제하여 다시 사용할 수 있도록 반환하는 함수이다

free(ptr);

malloc()으로 확보한 배열 공간도 한 번의 free()로 전체 블록을 해제한다.

int* arr = malloc(sizeof(int) * n);
 
/* 할당 성공 확인 후 사용 */
 
free(arr);

각 정수 원소를 별도로 해제하는 것이 아니다

할당대응하는 해제
malloc(sizeof(int))free(ptr)
malloc(sizeof(int) * n)free(arr)

free()에는 할당받은 블록의 시작 주소를 전달해야 한다. 일반적인 지역 변수의 주소나 배열 중간의 주소를 전달하면 안 된다

int value = 10;
 
free(&value); // 잘못된 해제
free(arr + 1); // 잘못된 해제

2.6 해제 후 포인터 상태

free(ptr)는 동적 메모리를 해제하지만, 포인터 변수 ptr 자체를 없애거나 자동으로 널 포인터로 바꾸지는 않는다

free(ptr);
ptr = NULL;

해제 후 NULL을 대입하면 해당 포인터를 통해 실수로 다시 접근하거나 중복 해제하는 일을 줄일 수 있다. free(NULL)은 아무 작업도 수행하지 않는다

다만 같은 주소를 복사해 둔 다른 포인터까지 바뀌지는 않는다

int* ptr = malloc(sizeof(int));
int* alias = ptr;
 
free(ptr);
ptr = NULL;
 
// alias도 해제된 공간을 가리키므로 사용할 수 없음

해제 후 접근과 중복 해제

해제한 메모리를 읽거나 쓰는 것과, 같은 메모리를 다시 해제하는 것은 정의되지 않은 동작이다. 이전 값이 출력되거나 프로그램이 정상 실행되는 것처럼 보여도 올바른 사용이 아니다

2.7 C와 C++에서의 사용 차이

malloc()의 반환형은 void*이다

C에서는 void*를 객체 포인터형으로 변환할 때 명시적인 형 변환이 필요하지 않다

int* ptr = malloc(sizeof(int));

C++에서는 명시적인 형 변환이 필요하다

#include <cstdlib>
 
int* ptr = static_cast<int*>(std::malloc(sizeof(int)));
구분CC++
헤더<stdlib.h><cstdlib>
함수 표기malloc(), free()std::malloc(), std::free()
void*에서 객체 포인터로 변환명시적 형 변환 불필요명시적 형 변환 필요

C++에서도 사용할 수 있지만, malloc()과 free()는 클래스의 생성자와 소멸자를 호출하지 않는다. 객체 생성과 소멸까지 처리하는 new와 delete는 다음 단원에서 다룬다






3. C++의 new와 delete, 메모리 관리

3.1 new와 delete

new와 delete

new는 동적으로 저장 공간을 확보하고 객체를 생성하는 연산자이며, delete는 대응하는 객체를 소멸시키고 저장 공간을 해제하는 연산자이다

int* ptr = new int;
*ptr = 10;
 
delete ptr;
코드의미
new int정수 객체 하나를 동적으로 생성
int* ptr생성한 객체의 주소를 저장
*ptr = 10생성한 정수 객체에 값 저장
delete ptr객체를 제거하고 저장 공간 해제

기본적인 new, delete 표현식 자체를 사용하기 위해 <cstdlib>를 포함할 필요는 없다

3.2 객체 생성과 초기화

new를 사용할 때 초기값을 지정할 수 있다

int* ptr = new int(10);

생성과 동시에 값이 10으로 초기화된다

표현정수 객체의 초기 상태
new int값이 초기화되지 않음
new int(10)10으로 초기화
new int()0으로 초기화
new int{10}10으로 초기화, C++11 이상
new int{}0으로 초기화, C++11 이상

new int만 사용했다면 값을 읽기 전에 대입해야 한다

int* ptr = new int;
*ptr = 10;
 
// 이후 *ptr의 값을 읽을 수 있음
 
delete ptr;

클래스 객체를 생성하면 해당 초기화 방식에 맞는 생성자가 호출된다. delete로 제거하면 소멸자가 호출된 뒤 저장 공간이 해제된다

3.3 동적 배열 생성

여러 원소를 가진 배열은 new[]로 생성한다

int n = 3;
int* arr = new int[n];

실행 중 결정된 n을 배열 크기로 사용할 수 있다. 다만 입력받은 크기는 적절한 값인지 먼저 확인해야 한다

각 원소에는 일반 배열처럼 접근한다

arr[0] = 10;
arr[1] = 20;
arr[2] = 30;

3.3.1 배열 원소의 초기화

int* arr = new int[n];   // 정수 원소가 초기화되지 않음

모든 원소를 0으로 초기화하려면 빈 괄호를 붙인다

int* arr = new int[n](); // 모든 정수 원소를 0으로 초기화

위 두 선언은 서로 다른 초기화 방식을 비교한 것이므로, 같은 범위에서 같은 이름으로 함께 선언하지 않는다

배열은 생성된 시점의 크기를 유지한다. 이후 n을 변경해도 기존 배열이 자동으로 늘어나거나 줄어들지는 않는다

3.4 단일 객체와 배열의 해제

생성 방식대응하는 해제
new Tdelete ptr
new T[n]delete[] ptr
int* value = new int(10);
int* numbers = new int[3]();
 
delete value;
delete[] numbers;

delete[] numbers는 동적으로 생성한 배열 전체를 제거한다. 원소가 클래스 객체라면 각 원소의 소멸자도 호출된다

할당 방식과 해제 방식의 일치

new에는 delete, new[]에는 delete[]를 사용해야 한다. malloc()으로 할당한 공간을 delete로 해제하거나, new로 생성한 객체를 free()로 해제하는 등 서로 섞어 사용하면 정의되지 않은 동작이 발생한다

3.5 malloc()과 new 비교

항목malloc()new 표현식
구분함수언어의 연산자
할당 크기 지정바이트 수를 직접 지정자료형과 원소 수로 결정
결과의 자료형void*생성한 객체에 맞는 포인터형
C++에서 포인터 형 변환명시적으로 필요일반적으로 불필요
초기값 지정별도 처리생성 시 지정 가능
클래스 생성자호출하지 않음호출함
일반적인 할당 실패널 포인터 반환std::bad_alloc 예외 발생
대응하는 해제free()delete, delete[]
int* ptr = new int(10);

이 표현에서는 정수 크기를 계산하는 sizeof나 반환값의 명시적인 포인터 형 변환이 필요하지 않다

일반적인 new의 할당 실패는 malloc()처럼 널 포인터를 반환하는 방식이 아니다. 널 포인터를 반환하도록 요청하는 std::nothrow 형식도 있지만, 기본적인 new와 구분해야 한다

3.6 해제 후 포인터와 댕글링 포인터

delete는 가리키는 객체를 제거하지만, 포인터 변수 자체를 없애거나 자동으로 nullptr로 변경하지는 않는다

int* ptr = new int(10);
 
delete ptr;
ptr = nullptr;

댕글링 포인터(Dangling Pointer)

수명이 끝난 객체나 해제된 저장 공간을 가리키는 포인터이다. 해당 포인터를 통해 객체에 접근해서는 안 된다

해제 후 nullptr를 대입하면 해당 포인터를 실수로 다시 사용하는 일을 줄일 수 있다. delete nullptr;는 아무 작업도 수행하지 않는다

다만 같은 주소를 복사해 둔 다른 포인터까지 변경되지는 않는다

int* ptr = new int(10);
int* alias = ptr;
 
delete ptr;
ptr = nullptr;
 
// alias는 여전히 해제된 공간을 가리키므로 사용할 수 없음

같은 동적 객체를 다시 삭제하거나, 해제 후 값을 읽고 쓰는 것은 잘못된 동작이다

3.7 메모리 누수가 발생하는 경우

3.7.1 해제 없이 포인터의 수명이 끝남

void func()
{
    int* ptr = new int(10);
    // 해제하지 않고 함수 종료
}

지역 포인터는 사라지지만 동적 객체는 남는다. 다른 곳에 주소를 보관하지 않았다면 접근할 방법도 잃는다

3.7.2 유일한 포인터를 다른 주소로 덮어씀

int* ptr = new int(10);
 
ptr = new int(20); // 첫 번째 객체의 주소를 잃음
 
delete ptr;       // 두 번째 객체만 해제

마지막에 delete를 호출했더라도, 첫 번째 객체는 해제되지 않는다

new와 delete의 문장 수를 맞추는 것만으로 올바른 관리가 보장되지는 않는다. 각 동적 객체가 사용을 마친 뒤 정확히 한 번 해제되는지 확인해야 한다

3.8 메모리 관리 책임

직접 동적 메모리를 관리할 때는 다음 내용을 명확히 해야 한다

확인할 내용핵심
누가 할당하는가객체를 생성하는 코드
누가 해제하는가메모리 관리 책임을 가진 코드
언제 해제하는가마지막 사용이 끝난 시점
어떤 방식으로 해제하는가생성 방식에 맞는 delete 또는 delete[]
다른 포인터가 남는가해제 후 접근 가능성 확인

할당과 해제를 서로 다른 함수에서 수행하는 것 자체가 문법 오류는 아니다. 다만 관리 책임이 불분명하면 누수나 중복 해제가 발생하기 쉽다

실제 C++ 프로그램에서는 std::vector, std::string, 스마트 포인터 등을 활용하여 자원의 수명을 자동으로 관리할 수 있다






4. 구조체와 다단계 동적 할당

4.1 구조체 하나의 동적 할당

구조체도 기본 자료형처럼 동적으로 생성할 수 있다

struct Point
{
    int xpos;
    int ypos;
};

4.1.1 C의 malloc() 사용

C에서 Point라는 자료형 별칭이 정의되어 있다면 다음과 같이 할당한다

Point* ptr = malloc(sizeof(Point));

할당에 성공한 뒤 ->로 멤버에 접근한다

ptr->xpos = 10;
ptr->ypos = 20;
 
free(ptr);

실제 코드에서는 ptr이 널 포인터인지 확인한 뒤 접근해야 한다

4.1.2 C++의 new 사용

Point* ptr = new Point;
 
ptr->xpos = 10;
ptr->ypos = 20;
 
delete ptr;

이 Point의 정수 멤버는 new Point만으로 초기화되지 않는다. 모두 0으로 초기화하려면 다음과 같이 작성할 수 있다

Point* ptr = new Point();
표현의미
ptr구조체 객체의 주소
*ptr구조체 객체 전체
ptr->xpos해당 객체의 xpos 멤버
ptr->ypos해당 객체의 ypos 멤버

4.2 구조체 배열의 동적 할당

구조체 여러 개를 연속된 공간에 저장할 수 있다

4.2.1 C의 malloc() 사용

Point* points = malloc(sizeof(Point) * n);

할당에 성공했다면 인덱스와 .으로 각 원소의 멤버에 접근한다

points[0].xpos = 10;
points[0].ypos = 20;
 
free(points);

4.2.2 C++의 new[] 사용

Point* points = new Point[n]();
 
points[0].xpos = 10;
points[0].ypos = 20;
 
delete[] points;

new Point[n]()는 위의 단순한 Point 구조체 배열에서 모든 정수 멤버를 0으로 초기화한다

표현의미
points첫 번째 구조체 원소의 주소
points[i]i번째 구조체 객체
points[i].xposi번째 객체의 xpos
(points + i)->xpos위 표현과 같은 멤버에 접근

배열 안의 xpos, ypos는 각 구조체의 일부이므로 따로 해제하지 않는다. 배열 전체를 한 번 해제한다

4.3 포인터 배열의 동적 할당

다단계 동적 할당

포인터를 저장하는 공간과 그 포인터들이 가리키는 공간을 각각 할당하는 구성이다. 각 공간은 별도로 관리해야 한다

정수 배열 두 개를 가리킬 포인터 배열은 다음과 같이 만들 수 있다

int** rows = new int*[2]();

이 코드가 생성하는 것은 정수 배열 두 개가 아니라, int* 원소 두 개를 가진 배열이다. 빈 괄호에 의해 두 포인터 원소는 널 포인터로 초기화된다

각 원소가 가리킬 배열은 별도로 생성한다

rows[0] = new int[4]();
rows[1] = new int[5]();
할당생성되는 공간
new int*[2]()포인터 2개를 저장하는 배열
new int[4]()정수 4개를 저장하는 배열
new int[5]()정수 5개를 저장하는 배열

각 행의 길이가 달라도 된다

rows[0][3] = 10;
rows[1][4] = 20;

rows[0]과 rows[1]이 가리키는 배열은 각각 연속된 공간이지만, 두 배열 사이까지 연속 배치된다고 보장되지는 않는다

또한 이 구성의 int**는 고정된 2차원 배열의 포인터형과 다르다

4.4 다단계 할당의 해제

각 행을 먼저 해제하고, 마지막으로 포인터 배열을 해제한다

delete[] rows[0];
delete[] rows[1];
delete[] rows;
 
rows = nullptr;

C의 malloc()으로 같은 구성을 만들었다면 각각 free()로 해제한다

free(rows[0]);
free(rows[1]);
free(rows);

바깥 배열만 해제하면 안 된다

포인터 배열을 해제해도 각 포인터가 가리키는 별도 동적 메모리는 자동으로 해제되지 않는다. 내부 공간을 먼저 해제하고 바깥 공간을 해제해야 주소를 잃거나 해제된 배열을 다시 참조하는 일을 피할 수 있다

할당된 대상대응하는 해제
첫 번째 정수 배열delete[] rows[0]
두 번째 정수 배열delete[] rows[1]
포인터 배열delete[] rows

4.5 구조체 배열과 구조체 포인터 배열

두 선언은 저장하는 대상과 할당 구조가 다르다

4.5.1 구조체 배열

Point* points = new Point[3]();

하나의 배열에 Point 객체 3개가 들어 있다

points[0].xpos = 10;
 
delete[] points;

4.5.2 구조체 포인터 배열

Point** points = new Point*[3]();
 
for (int i = 0; i < 3; ++i)
{
    points[i] = new Point();
}

바깥 배열에는 포인터 3개가 들어 있고, 각 포인터가 별도로 생성한 Point를 가리킨다

points[0]->xpos = 10;

해제할 때는 개별 객체와 포인터 배열을 구분한다

for (int i = 0; i < 3; ++i)
{
    delete points[i];
}
 
delete[] points;
구분구조체 배열구조체 포인터 배열
선언Point* pointsPoint** points
배열 원소Point 객체Point* 포인터
멤버 접근points[i].xpospoints[i]->xpos
위 코드의 할당 횟수1회바깥 배열 1회 + 객체 3회
해제배열 전체 해제개별 객체 해제 후 포인터 배열 해제

위 코드는 모든 할당이 성공한 경우의 기본 구조를 나타낸다. 중간에 할당이 실패했다면 앞서 확보한 공간도 정리해야 한다

4.6 포인터 멤버로 구조체 연결

구조체 안에 같은 자료형의 다른 객체를 가리키는 포인터를 둘 수 있다

struct Point
{
    int xpos;
    int ypos;
    Point* next;
};

객체 두 개를 생성하여 연결할 수 있다

Point* first = new Point();
Point* second = new Point();
 
first->xpos = 1;
first->ypos = 2;
 
second->xpos = 3;
second->ypos = 4;
 
first->next = second;
표현의미
first->next두 번째 객체의 주소
first->next->xpos두 번째 객체의 xpos, 즉 3
first->next->ypos두 번째 객체의 ypos, 즉 4

next는 포인터이므로 ->를 연속해서 사용한다

4.6.1 연결된 객체의 해제

delete first;
delete second;

이 구조체에서는 delete first;를 실행해도 first->next가 가리키던 객체까지 자동으로 삭제되지 않는다. 포인터 멤버의 존재만으로 대상의 소유권이나 자동 해제가 정해지지는 않는다

연결을 따라 여러 객체를 해제할 때는 다음 객체의 주소를 먼저 보관해야 한다

Point* current = first;
 
while (current != nullptr)
{
    Point* next = current->next;
    delete current;
    current = next;
}

이 방식은 각 객체를 개별적으로 new로 생성했고, 마지막 next가 널 포인터이며 순환하지 않는 연결을 전제로 한다. 위 반복문으로 삭제했다면 first, second를 다시 삭제하면 안 된다






5. 참조와 포인터의 차이

5.1 초기화의 차이

일반적인 지역 참조 변수는 선언할 때 참조 대상을 지정해야 한다

int value = 10;
int& ref = value;
 
int& ref; // 오류: 참조 대상이 없음

포인터는 초기화 없이 선언할 수 있지만, 유효한 주소를 저장하기 전에는 사용하면 안 된다. 아직 대상이 없다면 nullptr로 초기화할 수 있다

int* ptr = nullptr;
ptr = &value;

5.2 대상 변경의 차이

포인터는 저장된 주소를 바꾸어 다른 객체를 가리킬 수 있다

int a = 1;
int b = 2;
 
int* ptr = &a;
ptr = &b; // 이제 b를 가리킴

참조는 초기화한 뒤 다른 객체의 별칭으로 바꿀 수 없다

int& ref = a;
ref = b;

ref = b;는 b의 값을 참조 대상인 a에 대입한다

실행 단계ab참조 대상
int& ref = a;12a
ref = b;22a
ref = 100;1002a

참조의 초기화는 대상을 정하고, 이후 대입은 그 대상의 값을 변경한다

5.3 널 상태의 차이

포인터는 아무 객체도 가리키지 않는 상태를 표현할 수 있다

int* ptr = nullptr;

참조에는 이에 대응하는 정상적인 널 상태가 없다

int& ref = nullptr; // 오류

다만 참조를 사용한다고 대상의 수명이 자동으로 보장되는 것은 아니다. 대상이 먼저 소멸하면 참조도 더 이상 유효하게 사용할 수 없다

참조와 대상의 수명

참조는 정상적인 널 상태를 갖지 않지만, 대상이 소멸한 뒤에도 이름이 남는 댕글링 참조는 발생할 수 있다. 참조를 사용하는 동안 대상 객체가 살아 있어야 한다

5.4 핵심 비교

구분일반 포인터 int*참조 int&
의미주소를 저장하는 객체기존 객체의 별칭
지역 변수 초기화선언 후 가능선언할 때 필요
대상 변경가능불가능
널 상태nullptr로 표현정상적인 널 상태 없음
원본 접근*ptrref
대상 소멸 후 접근불가능불가능





6. 참조 매개변수와 const 참조

6.1 값, 포인터, 참조 매개변수 비교

매개변수전달 방식매개변수를 통한 원본 값 변경
int value값의 복사본 전달불가능
int* ptr주소 값의 복사본 전달*ptr로 가능
int& ref원본의 별칭으로 사용ref로 가능
void change(int value, int* ptr, int& ref)
{
    value = 40;
    *ptr = 50;
    ref = 60;
}
int a = 10;
int b = 20;
int c = 30;
 
change(a, &b, c);

호출 후 원본의 값은 다음과 같다

변수호출 전호출 후이유
a1010복사본 value만 변경
b2050*ptr로 원본 변경
c3060별칭 ref로 원본 변경

참조 매개변수는 원본을 변경하는 동시에 불필요한 객체 복사를 피할 때 사용할 수 있다

6.2 const 참조 매개변수

const 참조 매개변수

객체를 참조하되, 해당 참조를 통한 객체의 변경을 제한하는 매개변수이다. 기존 객체를 읽기만 하면서 복사를 피할 때 사용한다

struct Point
{
    int x;
    int y;
};
 
int sumCoordinates(const Point& p)
{
    return p.x + p.y;
}
Point pos = {10, 20};
int sum = sumCoordinates(pos); // 30

p를 통해 값을 읽을 수 있지만, 일반적인 멤버 값을 변경할 수는 없다

p.x = 100; // 오류

Point처럼 같은 자료형의 기존 객체를 전달하면 원본을 그대로 참조한다. 다만 자료형 변환이 필요하면 임시 객체가 생성될 수도 있다

6.3 const 참조와 원본의 변경

const 참조는 그 참조를 통한 변경을 제한한다. 원본 객체 자체를 반드시 상수로 만드는 것은 아니다

int value = 10;
const int& ref = value;
 
// ref = 20; // 오류
value = 20;  // 가능

이후 ref로 읽으면 변경된 값인 20을 얻는다

코드결과
ref = 20;const 참조를 통한 변경이므로 오류
value = 20;원본이 const가 아니므로 가능
ref의 값 읽기원본의 현재 값인 20

읽기 전용 참조는 원본의 불변성을 보장하지 않는다

const T&는 그 참조를 통한 변경을 제한한다. 원본이 변경 가능한 객체라면 다른 이름이나 포인터 등을 통해 값이 바뀔 수 있으며, 참조는 변경된 값을 읽는다

6.4 값 매개변수의 const와 비교

void process(const int value);
void inspect(const Point& point);

두 선언에서 const가 제한하는 대상은 다르다

선언복사 여부변경이 제한되는 대상
const int value정수 값 복사함수 안의 복사본
const Point& point같은 자료형의 기존 객체 전달 시 복사하지 않음참조를 통한 원본 변경

값 매개변수에 const를 붙여도 참조 전달로 바뀌지는 않는다

void process(const int value)
{
    value = 20; // 오류: 복사본의 변경을 제한
}

또한 일반적인 값 매개변수의 최상위 const만으로는 오버로딩할 수 없다

void process(int value);
void process(const int value); // 같은 함수의 선언

6.5 const 포인터와 const 참조

원본을 복사하지 않고 읽는 방법으로 두 형태를 사용할 수 있다

int sumPointer(const Point* p)
{
    return p->x + p->y;
}
 
int sumReference(const Point& p)
{
    return p.x + p.y;
}
구분const Point* pconst Point& p
호출sumPointer(&pos)sumReference(pos)
멤버 접근p->xp.x
해당 경로를 통한 일반 멤버 변경불가능불가능
정상적인 널 상태가능없음

위의 sumPointer()는 내부에서 바로 간접 참조하므로, 호출자가 유효한 객체의 주소를 전달해야 한다

두 방식 모두 대상의 수명을 자동으로 연장하거나 관리하는 것은 아니다

6.6 매개변수 방식 선택

목적일반적인 선택
작은 기본 자료형의 값을 전달int, double 등의 값 전달
기존 객체를 변경T&
큰 객체를 읽기만 하며 복사를 피함const T&
대상이 없을 수 있음을 표현T* 또는 const T*

참조 전달이 언제나 값 전달보다 좋은 것은 아니다. 작은 기본 자료형은 값 전달이 간단하고 적절한 경우가 많다. 원본 변경 여부, 복사 비용, 대상의 존재 여부에 맞추어 선택한다






7. 컴파일, 링크, 헤더와 Makefile

7.1 프로그램의 빌드 과정

빌드(Build)

소스 코드와 필요한 라이브러리를 처리하여 실행 가능한 프로그램을 만드는 과정이다

C++ 프로그램의 빌드 과정은 일반적으로 다음 단계로 구분한다

단계주요 작업결과
전처리#include, 매크로, 조건부 컴파일 처리전처리된 소스
컴파일문법과 자료형 검사, 코드 변환어셈블리 코드
어셈블기계어 명령으로 변환목적 파일
링크목적 파일과 라이브러리를 연결실행 파일

컴파일이라는 말을 전처리부터 목적 파일 생성까지의 과정을 포함하는 넓은 의미로 사용하기도 한다

7.2 컴파일과 목적 파일

목적 파일(Object File)

소스 파일을 번역하여 만든 기계어 코드와 심벌 정보 등을 포함하는 중간 결과물이다. 일반적으로 다른 목적 파일이나 라이브러리와 연결해야 실행 파일을 만들 수 있다

Linux 환경에서는 보통 .o 확장자를 사용한다

g++ -c main.cpp

위 명령은 main.cpp를 처리하여 main.o를 만든다

요소의미
g++C++ 빌드 도구
-c링크하지 않고 목적 파일까지만 생성
main.cpp입력 소스 파일
main.o생성된 목적 파일

목적 파일의 이름을 직접 지정할 수도 있다

g++ -c main.cpp -o main.o

7.3 함수의 선언과 정의

함수를 호출하는 코드를 컴파일하려면, 호출 지점에서 해당 함수의 선언을 알아야 한다

void printHello();

이 선언은 함수의 이름, 반환형, 매개변수 정보를 알려 준다

int main()
{
    printHello();
    return 0;
}

실제 동작은 함수 정의에 작성한다

void printHello()
{
    std::cout << "Hello\n";
}
구분역할
선언함수의 사용 방법을 알림
정의함수의 실제 구현을 제공

일반적인 비인라인 함수를 다른 소스 파일에 정의했다면, 호출 코드를 컴파일할 때는 선언을 사용하고 링크할 때 정의를 연결할 수 있다

7.4 헤더 파일과 소스 파일

헤더 파일(Header File)

여러 소스 파일이 공유하는 선언과 자료형 정의 등을 담는 파일이다. 다른 코드에서 사용할 인터페이스를 제공한다

일반적인 사용자 헤더는 .h, .hpp 등의 확장자를 사용한다

파일주로 작성하는 내용
헤더 파일함수 선언, 구조체와 클래스 정의 등
소스 파일함수와 멤버 함수의 구현 등

헤더에는 다음 내용도 들어갈 수 있다

  • 외부 변수의 선언
  • 열거형 정의
  • 매크로 정의
  • 인라인 함수와 템플릿의 정의

7.4.1 파일 분리

hello.h — 함수 선언

#ifndef HELLO_H
#define HELLO_H
 
void printHello();
 
#endif

#ifndef, #define, #endif는 같은 번역 단위에서 헤더 내용이 중복 포함되는 일을 막는 헤더 가드이다

hello.cpp — 함수 정의

#include "hello.h"
#include <iostream>
 
void printHello()
{
    std::cout << "Hello\n";
}

main.cpp — 함수 사용

#include "hello.h"
 
int main()
{
    printHello();
    return 0;
}

구현 파일에서도 자신의 헤더를 포함하면 선언과 정의가 일치하는지 확인하는 데 도움이 된다

7.4.2 #include의 역할

전처리기는 #include로 지정한 파일의 내용을 가져와 처리한다

#include "hello.h"

이를 통해 main.cpp에서도 printHello()의 선언을 알 수 있다

헤더 포함과 구현 연결은 다르다

함수 선언이 있는 헤더를 포함해도 다른 소스 파일의 구현이 자동으로 빌드되어 연결되지는 않는다. 실행 파일을 만들 때 해당 구현의 목적 파일이나 라이브러리도 함께 연결해야 한다

링크(Link)

목적 파일과 라이브러리의 코드 등을 연결하고, 함수와 변수에 대한 참조를 해결하여 실행 파일 등을 만드는 과정이다

앞의 두 소스 파일을 각각 컴파일한다

g++ -c main.cpp -o main.o
g++ -c hello.cpp -o hello.o

생성된 목적 파일을 연결한다

g++ main.o hello.o -o app

실행은 다음과 같이 한다

./app

목적 파일 생성과 링크를 한 명령으로 수행할 수도 있다

g++ main.cpp hello.cpp -o app
명령수행 작업
g++ -c main.cpp목적 파일 생성
g++ main.o hello.o -o app목적 파일 링크
g++ main.cpp hello.cpp -o app컴파일과 링크 수행

-o 다음에는 출력 파일 이름을 지정한다

7.6 라이브러리와 헤더의 관계

라이브러리를 사용하는 경우에도 선언과 구현의 역할을 구분해야 한다

구분제공하는 내용
라이브러리 헤더함수와 자료형 등의 선언
라이브러리함수 등의 구현 코드

예를 들어 수학 함수의 헤더는 함수의 사용 방법을 알려 주고, 실제 구현은 빌드 환경의 수학 라이브러리 등을 통해 제공될 수 있다

헤더만 포함했다고 모든 외부 라이브러리가 자동으로 연결되는 것은 아니다. 라이브러리와 빌드 환경에 따라 별도의 링크 옵션이 필요하다

정적 라이브러리는 일반적으로 목적 파일을 묶은 형태이며, 공유 라이브러리는 프로그램 실행 시 로더 등을 통해 연결되는 형태로 제공된다

7.7 컴파일 오류와 링크 오류

구분발생 원인예시
컴파일 오류소스 코드의 문법이나 자료형 등에 문제선언되지 않은 함수 호출
링크 오류필요한 정의를 찾지 못하거나 정의가 충돌구현 파일 누락, 중복 정의

7.7.1 선언이 없는 경우

int main()
{
    printHello(); // 보이는 선언이 없다면 컴파일 오류
}

7.7.2 선언만 있고 정의가 연결되지 않은 경우

void printHello();
 
int main()
{
    printHello();
}

이 코드는 목적 파일 생성까지 성공할 수 있지만, printHello()의 정의가 연결되지 않으면 일반적으로 링크 오류가 발생한다

호출 방법을 아는 것과 실제 구현을 확보하는 것은 별개이다

7.8 여러 파일과 의존성

프로젝트가 커지면 수정한 파일과 그 영향을 받는 파일만 다시 빌드하는 것이 효율적이다

앞의 파일 구성에서는 다음 관계가 있다

대상의존하는 파일
main.omain.cpp, hello.h
hello.ohello.cpp, hello.h
appmain.o, hello.o

예를 들어 hello.cpp만 수정했다면 다음 작업이 필요하다

  1. hello.cpp를 다시 컴파일한다
  2. 새 hello.o와 기존 main.o를 다시 링크한다

공통 헤더 hello.h를 수정했다면, 그 헤더를 사용하는 두 소스 파일 모두 재컴파일 대상이 될 수 있다

7.9 Makefile과 make

Makefile

빌드 대상, 의존 파일, 실행할 명령을 규칙으로 작성한 파일이다. make는 이 규칙과 파일의 수정 시각 등을 바탕으로 필요한 작업을 수행한다

기본 규칙은 다음 형태이다

대상: 의존파일
	실행할 명령

명령 줄 앞에는 기본적으로 탭 문자를 사용한다

앞의 프로그램을 위한 Makefile은 다음과 같다

app: main.o hello.o
	g++ main.o hello.o -o app

main.o: main.cpp hello.h
	g++ -c main.cpp -o main.o

hello.o: hello.cpp hello.h
	g++ -c hello.cpp -o hello.o
구성 요소역할
app만들 빌드 대상
main.o hello.o실행 파일이 의존하는 파일
g++ ...대상을 생성하거나 갱신할 명령

7.9.1 실행

Makefile이 있는 디렉터리에서 다음 명령을 실행한다

make

일반적으로 별도 대상을 지정하지 않으면 첫 번째 기본 대상인 app을 빌드한다

처음 실행하면 목적 파일과 실행 파일을 생성한다. 이후 파일 변경 여부에 따라 필요한 작업만 수행한다

변경 사항일반적으로 수행되는 작업
변경 없음재빌드하지 않음
hello.cpp 수정hello.o 재생성 후 링크
main.cpp 수정main.o 재생성 후 링크
hello.h 수정두 목적 파일 재생성 후 링크

Makefile은 내용을 직접 이해하여 모든 의존성을 알아내는 것이 아니다. 필요한 의존 관계를 규칙에 올바르게 작성해야 변경 사항을 반영할 수 있다