
동적 배열 할당, 해제
단일 값을 동적할당 하는 것 뿐 아니라 배열도 동적할당이 가능하다 (C-style 배열)
배열의 길이가 컴파일타임에 정해져야 하는 고정 배열과 다르게 배열을 동적으로 할당하면 런타임에 배열의 길이를 선택할 수 있다 (배열의 길이가 constexpr일 필요가 없다)
마찬가지로 new, delete 연산자를 사용하는데 [ ]를 붙여줘야 한다
#include <cstddef> //std::size_t
int main()
{
std::cout << "Enter Array size";
std::size_t length{};
std::cin >> length;
//동적 배열 할당
int* dynamicArray{ new int[length] {} }; //배열의 길이가 constexpr이 아니어도 됨, {}로 0으로 전부 값 초기화
dynamicArray[0] = 1;
dynamicArray[1] = 2;
//할당한 동적 배열 해제
delete[] array;
return 0;
}
동적으로 할당된 배열의 길이는 std::size_t 타입을 가진다, signed int를 사용한다면 narrowing convertion 경고가 발생할 수 있다 (static_cast<std::size_t> 해주거나 아예 std::size_t값으로 넣어주는게 좋다)
동적 배열 할당도 마찬가지로 일반적으로 heap메모리 할당되기 때문에 배열의 크기가 매우 커질 수 있다 (stack메모리보다 heap메모리의 크기가 훨씬 크기 때문)
따라서 보통 많은 메모리를 할당해야 하는 경우에 heap에 동적할당해서 사용한다
동적 할당된 배열을 해제하고 싶을때는 단순히 delete가 아닌 delete[]를 사용해야 한다 (굉장히 많이 하는 휴먼에러)
그냥 delete만 하게 된다면 메모리 누수, 프로그램 충돌 등 정의되지 않은 동작이 발생할 수 있다, delete[]로 동적 할당된 배열을 해제해야 한다 (그냥 delete는 배열의 첫번째 element에 대한 소멸자만 호출되거나 할당된 메모리 블록의 일부만 해제된다)
배열을 동적 할당 할 때 new[]에서 얼마나 많은 메모리가 할당되었는지 추적하기 때문에 delete[]에서 할당받은 메모리 크기만큼 해제가 가능하다 (프로그래머가 이 메모리 크기에 직접 접근할 수는 없다)
동적배열 vs 고정배열
고정 배열은 첫 번째 배열 element의 주소를 가지고 있고 이는 곧 첫 번째 배열 element의 주소를 가리키는 포인터로 decay 될 수 있다고 정리했다
decay된 고정 배열은 길이 정보가 없기 때문에 길이를 사용할 수 없다 (sizeof()로 배열의 크기도 알 수 없음)
동적 배열도 마찬가지이다, 배열의 첫 번째 element 주소를 가리키는 포인터이기 때문에 결국 길이를 알 수 없다, 따로 delete[]로 동적 배열을 해제해야 한다는 점과 런타임에 배열크기를 지정할 수 있다는 점 외에 고정 배열과 크게 다른점은 없다
동적 할당 배열 초기화
동적 할당된 배열을 0으로 초기화 하고 싶다면 {}로 리스트 초기화 방법의 값 초기화 해주면 된다
int* arr{ new int[length]() }; //C++03
int* arr{ new int[length]{} }; //C++11
C++11이전에는 {}로 초기화가 불가능했다, 따라서 모든 element에 인덱싱하여 값을 초기화 해주어야 했다 (휴먼에러 발생, 잘못된 값 들어갈 수 있음)
다른 값들로 초기화 하고 싶다면 아래와 같이 가능하다
std::size_t length{ 10 };
int* arr{ new int[length] { 1, 2, 3, 4, 5 } };
이는 고정배열도 마찬가지이다
int fixedArr[]{ 1, 2, 3, 4, 5 }; //배열 length 추론
char fixedArr[]{ "Hello" }; //배열 length 추론
동적 할당 배열 크기 조절
배열을 동적으로 할당하면 할당 시점에 배열 길이를 지정할 수 있다, 그러나 이미 동적 할당된 배열의 길이를 조절하는 방법은 제공되지 않는다
새로운 배열을 동적으로 할당하고 element들을 전부 복사한 뒤 기존에 할당된 배열을 해제하는 방법을 사용해야 한다
(에러 발생하기가 쉽다, 좋은 방법은 아님)
std::size_t length{ 5 };
int* arr = new int[5] {1, 2, 3, 4, 5};
int* arr2 = new int[7] {};
std::copy(arr, arr + length, arr2);
delete[] arr;
std::cout << arr2[0];
delete[] arr2;
그냥 이러한 런타임에 크기 조절이 필요한 경우에는 std::vector를 사용하는걸 권장한다
소멸자
앞서 정리한 소멸자를 delete와 함께 다시 정리해보자
클래스의 멤버로 동적 할당 시킬 변수를 가지고 있고 이를 동적 할당 한다면 소멸자에서 해당 객체를 delete해주는게 좋다
#include <cassert> // assert
#include <cstddef> // std::size_t
class IntArray
{
private:
int* m_array{};
int m_length{};
public:
IntArray(int length)
{
assert(length > 0);
m_array = new int[static_cast<std::size_t>(length)] {}; //멤버 배열 동적 할당
m_length = length;
}
~IntArray()
{
delete[] m_array; //동적 할당 된 멤버 배열 해제
}
void setValue(int index, int value) { m_array[index] = value; }
int getValue(int index) { return m_array[index]; }
int getLength() { return m_length; }
};
int main()
{
IntArray ar(10);
for (int count{ 0 }; count < ar.getLength(); ++count)
ar.setValue(count, count + 1);
std::cout << "The value of element 5 is: " << ar.getValue(5) << '\n';
return 0;
} ~IntArray() 소멸자 호출
RAII (Resource Acquisition Is Initialization)
RAII는 쉽게 설명하면 resource 사용을 자동 기간 객체(stack에 할당된 객체)의 생명주기에 연결하는 프로그래밍 기법이다
곧 특정 클래스의 생성자에서 resource가 획득되고 이 resource는 특정 클래스의 소멸자에서 해제되는 방식으로 해당 resource는 객체의 생명주기동안 사용이 가능하게 되는 것이다
(위 코드가 대표적인 RAII 구현임, std::string과 std::vector는 RAII를 따르는 클래스이다)
생성자와 소멸자를 통해 resource 할당, 해제를 관리하기 때문에 프로그래머가 resource를 해제하는걸 잊어버리는 휴먼 에러를 방지할 수 있다
또한 함수 처리 중 예외가 발생하여 정상적인 흐름을 벗어나도 stack unwinding(스택 되감기)를 통해 local 객체들의 소멸자가 호출되기 때문에 resource가 알아서 해제되어 메모리 누수 방지가 가능하다
마지막으로 소멸자에서 resource가 해제되어 캡슐화 되기 때문에 해당 resource 해제 코드를 이곳저곳에 분산시킬 필요가 없어진다
이때 std::exit()을 하게 되면 프로그램이 종료되고 소멸자가 호출되지 않는다, 따라서 std::exit()이 호출되게 되면 소멸자가 호출되지 않아 resource가 해제되지 않을 수 있다 (정상적인 프로그램 종료에서는 stack에 올라간 객체들의 소멸자가 호출됨)
다중 포인터
포인터는 특정 객체의 메모리 주소를 담고 있다, 포인터의 주소를 담고 있는것이 바로 다중 포인터이다
int a{ 10 };
int* aptr{ &a }; //포인터
int** aptrptr{ &aptr };
다중 포인터는 일반 포인터처럼 동작한다, 역참조하여 가리키는 값을 가져올 수 있고 또 한번 역참조하여 근본적인 값에 접근이 가능하다
**aptrptr; //10
다중 포인터에 다음과 같은 값 설정은 불가능하다
int** aptrptr{ &&a }; //compile error
operator&는 lvalue를 요구하지만 &a는 rvalue이기 때문에 컴파일 에러가 발생한다
(&a는 메모리에 저장된 변수가 아니라 주소이기 때문)
다중 포인터는 nullptr로 설정될 수 있다
int** aptrptr{ nullptr };
그렇다면 이러한 다중 포인터는 어디에 사용될까?
가장 일반적으로 사용되는곳은 포인터의 배열을 동적 할당할때 많이 사용된다
int* array1{ new int[10] };
int** array2{ new int* [10] };
int* 타입의 배열 10칸짜리의 주소를 담아야 하기때문에 주소의 주소를 담을 수 있는 다중 포인터를 사용한 것이다
이러한 포인터 타입 배열도 마찬가지로 메모리 해제는 delete[]로 처리한다
다음은 동적 할당된 다차원 배열을 용이하게 하는것이다
고정 다차원 배열은 다음과 같이 쉽게 선언이 가능하다
int arr[10][5];
하지만 다차원 배열을 동적할당 할 때 같은 방식으로 하면 컴파일 에러가 발생한다
int* array{ new int[10][5] }; //compile error
C++의 new 연산자는 직접적으로 다차원 배열을 동적할당 하는 방식을 지원하지 않는다 new는 기본적으로 1차원 배열을 할당한다
다차원 배열 동적할당은 다음과 같이 처리한다
int x{ 2 };
int(*array)[3] { new int[x][3] };
여기서 (*array)로 array가 포인터로 간주되고 포인터 타입인 array를 역참조하면 heap에 할당된 메모리 주소가 나온다는 의미가 된다
new는 기본적으로 1차원 배열을 할당하기 때문에 new int[x][3]로 int[2 X 3] 1차원 배열이 할당된다
여기서 array의 모든 element가 1, 2, 3, 4, 5, 6으로 초기화 되어 있고 array[1][1]하면 어떤 값이 나오게 될까?
int x{ 2 };
int(*array)[3] { new int[x][3]{ 1, 2, 3, 4, 5, 6 } };
std::cout << array[1][1] << std::endl; // 5
[ 1 ] [ 2 ] [ 3 ]
[ 4 ] [ 5 ] [ 6 ]
[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] [ 6 ]
array[1][1]에서 앞의 [1]은 int (*)[3]이기 때문에 행을 의미한다([1][2][3], [4][5][6]), 따라서 1행인 4,5,6이 나오게 된다
그리고 뒤의 [1]은 열을 의미해서 결과는 5가 나오게 되는것이다
이렇게 int(*)[] 타입을 사용하기 굉장히 불편하기 때문에 auto를 사용하면 더욱 가독성 좋고 편하게 사용이 가능하다
int x{ 2 };
auto array { new int[x][3]{ 1, 2, 3, 4, 5, 6 } }; //auto로 타입 추론 (C++11)
std::cout << array[1][1] << std::endl; // 5
하지만 이러한 해결책은 열의 개수가 컴파일 타임 상수가 아닌경우에는 동작하지 않는다는 단점이 있다
만약 행,열의 개수가 컴파일 타임 상수가 아니면 다음과 같이 처리해야 한다
int row{ 2 };
int col{ 3 };
int** array{ new int* [row] };
for (int i{ 0 }; i < row; ++i)
{
array[i] = new int[col];
}
int* 타입의 크기가 2인 동적 할당된 배열을 가리키는 array를 만들어준다 (행을 의미)
각 행에 열의 개수만큼을 크기로 가지는 int타입 배열을 넣어준다 (열을 의미)
결국 2X3 행렬이 만들어지게 되는것이다
이렇게 만들면 2차원 배열을 직사각형 형태가 아닌 삼각형 모양으로도 만들 수 있다
int row{ 10 };
int** array{ new int* [row] };
for (int i{ 0 }; i < row; ++i)
{
array[i] = new int[i + 1];
}
array[0]은 길이가 1개인 배열, array[1]은 길이가 2개인 배열... 해서 삼각형 모양의 다차원 배열이 만들어진다
이렇게 동적 할당 된 배열들은 delete도 loop로 해줘야 한다 (loop를 통해 new 되었기 때문에 다 delete해줘야 함)
이때 순서는 배열을 생성할때와 반대로 열부터 해제해야 한다 (그렇지 않으면 이미 해제된 메모리에 접근해야 한다)
int row{ 2 };
int col{ 3 };
int** array{ new int* [row] };
for (int i{ 0 }; i < row; ++i)
{
array[i] = new int[col];
}
for (int i{ 0 }; i < row; ++i)
{
delete[] array[i]; //열 해제
}
delete[] array; //행 해제
array = nullptr;
이렇게 2차원 배열을 동적 할당하고 해제하는건 굉장히 복잡하고 휴먼 에러 발생률이 높다, 따라서 2차원 배열을 1차원 배열로 평탄화 해서 사용하는게 더 쉽다
int** array { new int*[2] };
for (int count { 0 }; count < 2; ++count)
array[count] = new int[3];
//이걸 아래처럼 처리하는게 더 깔끔함
int rows = 2;
int cols = 3;
int* flat_array { new int[rows * cols] }; // 2x3 배열을 단일 배열로 평탄화
그리고 row, col, col개수를 받아 1차원 배열에 접근할 수 있게 인덱스를 만들어주는 함수를 하나 만들어준다
int getSingleIndex(int row, int col, int numberOfColumnsInArray)
{
return (row * numberOfColumnsInArray) + col;
}
int main()
{
int row{ 2 };
int col{ 3 };
int* flatarr{ new int[row * col] {} };
flatarr[getSingleIndex(1, 2, 3)] = 3;
//1 * 3 + 2 = 5번째 == [1][2]
delete[] flattarr;
return 0;
}
배열 동적 할당과 해제가 더욱 간편해 졌다
하지만 C++에서는 동적 다차원 배열을 구현할 때 std::vector를 주로 사용한다 (std::vector자체가 동적 할당으로 동작한다 (런타임에 필요에 따라 메모리 크기를 늘리고 줄이기가 가능함))
int rows = 3;
int cols = 4;
int initial_value = 0;
std::vector<std::vector<int>> matrix1(rows, std::vector<int>(cols, initial_value));
matrix1[0][0] = 1;
matrix1[1][1] = 5;
matrix1[2][3] = 9;
마지막으로 포인터를 주소로 전달할 때 다중 포인터를 사용한다 (passing a pointer by address)
void foo(int** ptr_addr) //2차원 포인터를 인자로 받는다
{
*ptr_addr = new int{ 10 }; //2차원 포인터를 역참조하여 접근한 포인터에 새로운 주소를 가리키도록 한다
}
int main()
{
int a{ 100 };
int* aptr{ &a };
foo(&aptr); //aptr포인터 변수는 new int{ 10 } 으로 동적할당된 메모리 주소를 가리키도록 변경된다
return 0;
}
이렇게 포인터의 주소를 넘겨야 할 경우에는 포인터에 대한 참조를 넘기는게 더 권장되는 방법이다 (그냥 포인터 타입으로만 받고 가리키는 대상을 변경하면 원본은 변경되지 않는다(주소를 담는 포인터의 사본이 전달되기 때문))
void foo(int*& ptr_addr) //포인터의 참조를 인자로 받는다
{
ptr_addr = new int{ 10 };
}
int main()
{
int a{ 100 };
int* aptr{ &a };
foo(aptr); //10을 가리키게 된다
return 0;
}
2차원 뿐 아니라 더 고차원의 포인터도 가능하다
int**** ptr4{ nullptr };
하지만 거의 사용되지 않는다
다른 방법이 없는 경우가 아니라면 다차원 포인터는 피하는걸 권장한다, 일반 포인터만 사용해도 dangling 포인터, nullptr 참조로 인해 의도치 않은 동작이 발생하고 크래시가 종종 나는데 다차원 포인터는 역참조 한 포인터의 dangling과 nullptr도 체크해야 하기 때문에 더욱 오류가 잦아질 수 있다