이번 글에서는 Chapter 6의 후반부를 다루게 된다.
Chapter 6.11 메모리 동적 할당 new와 delete
이번 챕터에서는 메모리를 동적으로 할당하는 방법에 대해서 다룬다.
메모리가 할당되는 방식은 총 3가지이다.
1) Static Memory allocation, 전역 변수나 static 변수처럼 한번 만들면 프로그램이 끝날 때까지 메모리를 가지고 있는 경우이다.
2) 자동 메모리 할당, 변수를 선언하거나 정적 배열을 선언했을 때 블럭 밖으로 나가면 전부 사라지고 메모리를 os에 반납하는 경우
3) 동적 메모리 할당
이번 챕터에서는 마지막에 있는 동적 메모리 할당에 대해서 다루는 것이다.
지금까지는 정적으로 메모리를 할당해 왔는데, 정적으로 할당하는 메모리는 스택에 들어간다. 스택은 용량이 작기 때문에, 다루어야 하는 데이터가 큰 경우는 메모리가 부족하게 된다.
반면 동적으로 메모리를 할당하는 경우는 힙을 사용하게 되고, 힙은 훨씬 크다. 따라서 프로그램이 커질수록 당연히 정적 할당보다는 동적 할당을 사용하게 된다.
예제 코드 1
int main()
{
int *ptr = new int; // 4 bytes로 받아온 다음에 메모리 주소르 우리에게 알려줍니다. 그래서 포인터로 받아야함.
//int *ptr = new (std::nothrow) int{ 7 }; // 오류를 발생시키지 않고 밀어붙이는 방법.
*ptr = 7;
delete ptr; // 프로그램이 끝나기 전에 메모리를 제가 먼저 반납하겠습니다 라는 의미.
ptr = nullptr; // 포인터가 가지고 있는 주소는 의미가 없는 값이다 라고 기록하는 것.
cout << "after delete" << endl;
// ptr의 주소가 의미가 있을 때만 dereference 하도록 만들어줌.
if (ptr != nullptr)
{
cout << ptr << endl;
cout << *ptr << endl; // 에러나옴.
}
new라는 키워드를 사용해서, 동적으로 할당을 받을 수 있다. 할당받은 ptr이라는 메모리 주소에, 7이라는 값을 *ptr = 7;을 통해 전달할 수 있다.
메모리를 할당받으려고 하는데, 다른 프로그램들이 메모리를 다 쓰고 있어서 못 받는 경우 프로그램이 죽어버리게 만들 수 있고 아니면 다른 프로그램이 다 쓸 때까지 기다렸다가 메모리를 할당받는 방법이 있다.
new로 동적 할당할 때 new (std::nothrow)를 걸어두면 오류를 발생하지 않고 다 쓸 때까지 기다리게 된다.
delete라는 키워드는 프로그램이 끝나기 전에 메모리를 먼저 반납하기 위한 방법으로, 이는 할당받은 메모리를 자발적으로 미리 os에게 돌려주는 방법이다.
데이터가 엄청 많아서 한 번에 데이터를 못 올리는 경우, 일부 데이터를 가지고 작업하고 다시 메모리를 반납했다가 다시 받고 이런 식으로 작업을 해야 할 수도 있고, 혹은 여러 프로그램들이 메모리를 많이 쓰는 경우에 메모리를 저 프로그램 줬다가 이 프로그램 줬다가 조절하면서 써야 할 수도 있는데 이런 경우 delete를 사용해서 적절하게 메모리 사용을 컨트롤할 수 있게 된다.
ptr의 메모리 주소를 이미 반납했기 때문에, 해당 포인터 변수는 의미가 없다는 것을 알려주기 위해 ptr = nullptr;로 선언해 준다.
ptr에 해당하는 메모리를 반납한 후에 ptr를 dereferencing 하면 당연히 에러가 발생하게 된다. 따라서 이러한 에러를 방지하기 위해서 if문으로 ptr이 nullptr가 아닌 경우에만 dereferencing 하도록 코드를 짜주는 것이 좋다.
예제 코드 2
int main()
{
while (true)
{
// memory leak
int *ptr = new int;
cout << ptr << endl;
delete ptr;
}
}
while문을 이용해서 새로운 포인터를 계속 할당받게 만드는 케이스이다.
이때 delete로 ptr pointer를 os에 반납해주지 않으면 메모리가 끝없이 차오르게 된다.
이것이 C++에서 가장 난감한 문제 중 하나인 메모리 누수 현상이다.
따라서 적절하게 할당받은 포인터를 반납하는 것이 중요하다.
추가로, new와 delete를 사용하는 것은 조금 느리기 때문에, 이를 적게 사용하는 방식으로 프로그래밍하는 것이 중요하다고 한다.
예제 코드 3
int main()
{
int *ptr = new (std::nothrow) int{ 7 };
int *ptr2 = ptr;
delete ptr;
ptr = nullptr;
//ptr2 = nullptr;
cout << *ptr2 << endl;
}
ptr를 선언하고, 새로운 포인터인 ptr2를 선언했는데 이게 ptr랑 동일한 주소를 사용하는 경우이다.
그다음 줄에서 ptr를 os에 반납했기 때문에, ptr2도 사실상 nullptr가 된 상황이다.
그런데 만약 ptr2를 nullptr로 처리해주지 않은 상황에서 ptr2에 대해서 dereferencing을 한다면 당연히 에러가 날 수밖에 없다.
최근에는 스마트 포인터를 이용해서 이런 케이스를 프로그래머가 조금 덜 걱정할 수 있도록 코딩을 할 수 있도록 C++ 언어가 진화하고 있다고 한다.
다만, 우리가 수동으로 직접 nullptr로 처리해 주는 것보다는 속도가 조금씩 느리기는 하다고 한다.
스마트 포인터 관련해서는 뒤에 다시 나온다고 하니, 추후에 나오는 내용을 봐야 할 것 같다.
Chapter 6.12 동적 할당 배열
이번 챕터에서는 기존에 정적으로 할당한 배열과 대비되는 방식인 동적 배열을 할당하는 방식에 대해서 알아본다.
예제 코드 1
int main()
{
int length;
cin >> length;
//int *array = new int[length] {11, 22, 33, 44, 55, 66};
int *array = new int[length]{};
array[0] = 1;
array[1] = 2;
for (int i = 0; i < length; i++)
{
cout << array[i] << " " << (uintptr_t)&array[i] << endl;
}
delete [] array;
기존에 정적 배열의 경우 길이가 const int로 반드시 정해져 있어야 했지만, 동적 배열을 할당할 때는 cin으로 길이를 별도로 받아서도 선언할 수 있다.
이때 new 키워드를 활용해서 배열을 선언해 주면 된다.
동적 배열의 경우도 정적 배열처럼 indexing을 통해서 접근하면 된다. new를 사용했으니 마지막에 delete를 꼭 해주는 것을 잊지 말자. 그리고 저번 챕터에서처럼 일반 단일 변수의 경우는 delete만 적어주면 되었는데, 배열의 경우는 delete []를 해줘야 한다는 점을 알아두어야 한다.
int *array = new int [length] {11, 22, 33, 44, 55, 66}; 와 같은 방식으로 구체적인 값을 줄 수도 있으며, 만약 길이가 10인데 6번째까지만 명시적으로 값을 주었다면 나머지는 0으로 초기화된다.
주의해야 할 점은 만약 위 케이스처럼 배열의 6번째까지 값을 주었는데, length를 4로 선언하게 되면 뒤에 2개는 주소를 할당받지 못해서 문제가 발생한다. 따라서 이 부분을 반드시 조심해야 한다.
new 키워드를 이용해서 배열을 만들어줄 때 length 부분(배열의 크기)은 반드시 지정해야 한다. 지정하지 않으면 컴파일이 되지 않는다.
그리고 동적 배열에서도 정적 배열처럼 동일하게 포인터 연산을 사용할 수 있다. *(array + 1)로 접근하면 두 번째 배열의 요소를 출력할 수 있다.
Chapter 6.13 포인터와 const
이번 챕터에서는 포인터에 const를 사용하는 경우에 대해서 알아본다.
예제 코드 1
int main()
{
int value = 5;
int *ptr = &value;
*ptr = 6;
int value = 5;
const int *ptr = &value;
//*ptr = 6; // const int는 포인터가 가리키는 대상을 변경할 수 없다.
value = 6;
cout << *ptr << endl;
}
윗 줄은 여태까지 써왔던 코드들과 유사하다.
value라는 int형 변수에 5의 값을 주고, int형 pointer 변수인 ptr에는 value의 주소를 대입해 준다.
그리고 역참조(dereferencing)를 이용해서 ptr의 주소가 가리키는 곳의 값을 6으로 바꿔주는 코드이다.
아래 코드는 윗 코드에 const를 적용한 경우이다.
int형 pointer 변수인 ptr에 const를 적용했다. const int *ptr인 경우, *ptr(dereference)를 이용해서 값을 바꿀 수 없게 된다.
근데 신기하게도 value = 6;으로 value라는 변수의 값을 6으로 바꿔주었을 땐 *ptr의 값이 5에서 6으로 변한다.
이는 const int가 dereference를 통해서 값을 바꿀 수 없다는 의미이지 ptr이 가리키는 주소의 값을 바꿀 수 없다는 것이 아니기 때문이다.
이게 사실 말로는 혼동이 올 수 있는데, 내가 이해할 때는 const int (*ptr)이라고 생각했다. 즉 *ptr을 하나의 변수처럼 생각하고, *ptr을 통해서 값을 바꿀 수 없다 로 이해하는 것이다.
예제 코드 2
int main()
{
int value1 = 5;
const int *ptr = &value1; // const는 가리키고 있는 주소에 있는 값을 안 바꾸겠다는 것이지 ptr 주소값을 안 바꾸겠다는 것은 아님.
int value2 = 6;
ptr = &value2; // ptr에 다른 주소를 주는건 가능
}
위에서 언급했듯이, const int *ptr의 경우는 *ptr을 이용한 값 변경만 불가능한 것이지 다른 작업은 모두 가능하다.
따라서 ptr가 가리키고 있는 메모리 주소는 변경할 수 있으며, 위 코드처럼 작성이 가능하다.
예제 코드 3
int main()
{
int value = 5;
int *const ptr = &value;
*ptr = 10;
int value2 = 8;
//ptr = &value2; // pointer의 주소값을 못 바꾸는 경우
}
이번 코드 예제에서는 const int가 아니라, int *const ptr인 경우이다.
이 경우에는 pointer 변수인 ptr의 주소값을 못 바꾸는 경우이다.
따라서 ptr = &value2; 와 같이 ptr이 가리키는 메모리 주소를 변경할 수 없다.
예제 코드 4
int main()
{
int value = 5;
const int *const ptr = &value; // 바꿀 수 없으니 반드시 초기화 있어야 함.
//*ptr = 10;
int value2 = 7;
//ptr = &value2;
}
마지막 예제이다.
이번에는 const int *const ptr인 케이스이다.
앞에서 나왔던 const int와 int *const가 모두 하나로 결합된 형태이다.
앞에서 언급했듯이 const int *ptr은 dereference를 통한 값 변경이 불가능했고, int *const는 ptr이 가리키고 있는 메모리 주소를 변경하는 것이 불가능했다.
따라서 const int *const는 두 기능이 모두 작동하여, *ptr = 10;와 같은 방식으로 dereference를 통한 값 변경이 불가능하고, ptr = &value2; 와 같은 방식으로 ptr이 가리키고 있는 메모리 주소를 변경하는 것 또한 불가능하다.
Chapter 6.14 참조 변수 reference variable
이번 강의에서는 포인터보다 조금 더 편하게 사용할 수 있는 참조 변수에 대해서 알아본다.
예제 코드 1
int main()
{
int value = 5;
int *ptr = nullptr;
ptr = &value;
int &ref = value; // 내부적으로 ref가 value와 같은 변수처럼 작동한다. 같은 메모리를 사용하는 것 처럼 작동함.
cout << ref << endl;
ref = 10; // *ptr = 10; 와 동일
cout << value << " " << ref << endl;
}
참조 변수라고 하는 부분은 바로 int &ref = value; 부분에 해당한다.
이전에 우리가 포인터를 배웠을 때, ptr = &value;와 같이 포인터 변수에 어떤 특정 변수의 주소를 가리키도록 했었는데,
이와 유사하게 int &ref = value;는 ref라는 변수가 value 변수와 동일한 메모리 주소를 사용하게 된다.
int &ref = value;를 통해서, ref는 value라는 변수의 '별명'처럼 사용할 수 있게 된다.
그래서 int &ref = value;를 선언한 이후에 ref의 값을 변경하면 value의 값도 함께 변경이 된다.
포인터를 사용했을 때는 *ptr = 10;처럼 *을 달아서 역참조를 해야만 값을 바꿔줄 수 있었지만, 참조 변수를 사용하게 되면 역참조를 사용하지 않고도 값을 바꿔줄 수 있다는 장점이 있다.
예제 코드 2
int main()
{
int value = 5;
int *ptr = nullptr;
ptr = &value;
int &ref = value;
cout << &value << endl;
cout << &ref << endl;
cout << ptr << endl;
cout << &ptr << endl; // pointer 변수도 변수다.
}
위 코드를 실행시켜 보면 다음과 같은 결과를 얻게 되는데,
value라는 변수의 주소는 00B5F768인 상황이고,
value의 참조 변수인 ref의 주소 &ref도 동일하게 00B5F768인 것을 확인할 수 있고,
value의 주소를 가리키고 있는 포인터 변수인 ptr의 값을 찍어보면 00B5F768인 것을 확인할 수 있다.
다만, 포인터 변수 또한 하나의 변수이기 때문에 포인터 변수의 주소는 별도이므로 다른 주소값이 찍히는 것을 확인할 수 있다.
예제 코드 3
int main()
{
//int &ref; // 참조 변수는 반드시 초기화가 되어야 하므로 불가능
//int &ref = 104; // 리터럴은 메모리 주소를 갖지를 못하기 때문에 사용 불가. 변수를 사용해야 함.
int x = 5;
int &ref = x;
const int y = 8;
//int &ref = y; // ref에서 y의 값을 바꿔버릴 수 있으므로 문법에서 허용하지 않는다. const를 사용하면 가능.
const int &ref = y; // 가능
}
참조 변수의 특징으로는, 어떤 특정 변수와 동일한 메모리 주소를 가리키는 역할을 하기 때문에 반드시 초기화가 되어야 한다.
그리고 마찬가지로, 리터럴은 메모리 주소를 갖지 못하기 때문에 참조 변수에 리터럴을 사용할 수는 없다.
const는 이전 강의들에서 언급되었듯이 상수로 고정되는 기능을 가지고 있는데, const로 선언된 변수에 대해서 참조 변수를 사용할 수는 없다. 왜냐하면 같은 메모리 공간을 공유하기 때문에, 참조 변수를 이용해서 변수의 값을 변경할 수 있기 때문이다.
따라서 const로 선언된 변수에 대해서 참조 변수를 사용한다면, 참조 변수 또한 const를 사용하여 변수를 선언하여야 한다.
예제 코드 4
int main()
{
int value1 = 5;
int value2 = 10;
int &ref1 = value1;
cout << ref1 << endl; // 5
ref1 = value2;
cout << ref1 << endl; // 10
}
위 코드에서는, ref1에 value1이라는 변수를 지정해 주었고, 그 이후에 다시 value2라는 변수의 별명으로 사용한 상황이다.
첫 번째는 value1의 값인 5가 출력되고, 두 번째는 value2의 값인 10이 출력되는 것을 확인할 수 있다.
따라서 참조 변수는 참조하는 변수를 바꿀 수 있다.
예제 코드 5
void doSomething(int n)
{
n = 10;
cout << "In do something " << n << endl;
}
int main()
{
int n = 5;
cout << n << endl;
doSomething(n);
cout << n << endl;
}
처음에 n의 값은 5였는데, 이를 doSomething이라는 함수에 전달하는 경우이다.
이전 강의에서도 언급되었지만, 함수의 인자로 주는 경우 값의 주소를 전달하는 것이 아니라 값을 복사한다고 얘기를 했었다.
따라서 함수 내부에서 n = 10;으로 n의 값을 10으로 바꾸더라도, 함수 밖으로 나와 main 문에서 n을 찍으면 5가 유지되는 것을 확인할 수 있다.
예제 코드 6
void doSomething2(int &n)
{
n = 10;
cout << "In do something " << n << endl;
cout << &n << endl;
}
int main()
{
int n = 5;
cout << n << endl;
cout << &n << endl;
doSomething2(n);
cout << n << endl;
}
이번에는 함수의 인자로 전달하는데, 그 대신 함수에서 받을 때 참조를 사용하는 경우이다.
이런 경우에는 값을 복사해서 함수 내에 들고 가는 것이 아니라, 주소 자체를 전달해 줘서 main문에서의 n과 함수 내에서의 n은 같은 주소를 의미하게 된다.
그래서 main 문에서 &n을 찍었을 때와 함수 내부에서 &n을 찍었을 때 주소 값이 같은 것을 확인할 수 있다.
변수 자체를 넘겨주는 것이라고 이해하면 된다.
이렇게 참조를 사용하게 되면 값을 별도로 복사할 필요가 없기 때문에 훨씬 효율이 좋다고 한다.
만약 n의 값을 바꾸지 못하게 하고 싶다면, const int로 받으면 된다.
예제 코드 7
struct Something
{
int v1;
float v2;
};
struct Other
{
Something st;
};
int main()
{
Other ot;
// 구조체가 여러개 얽혀있어서 복잡할 때 reference를 쓰면 쉽게 접근할 수 있다.
int &v1 = ot.st.v1;
//ot.st.v1 = 1;
v1 = 1;
}
위 구조처럼, 구조체가 여러 개로 얽혀있는 경우, 변수를 호출하려고 할 때 여러 번 들어가야 하는 경우가 발생한다.
이런 경우에 참조 변수를 활용하게 되면 길게 사용하지 않고도 훨씬 쉽게 변수에 접근할 수 있다.
예제 코드 8
int main()
{
int value = 5;
int *const ptr = &value; // *const ptr는 주소값 변경 불가
int &ref = value;
// 두개가 동일.
*ptr = 10;
ref = 10;
}
ptr은 현재 value의 주소를 가리키고 있는 pointer 변수가 되고, ref는 value의 참조 변수인 상황이다.
pointer에서는 역참조를 활용해서 *ptr = 10;와 같은 방식으로 값을 바꿔줄 수 있는데, 이를 참조 변수를 활용하면 ref = 10;와 같이 훨씬 깔끔하게 할 수 있는 모습을 볼 수 있다.
참조 변수도 내부적으로는 pointer로 구현되어 있다고 한다.
Chapter 6.15 참조와 const
이번 챕터에서는 참조 변수에 const를 사용할 때 여러 가지 케이스에 대해서 다룬다.
예제 코드 1
void doSomething(const int &x)
{
cout << x << endl;
}
int main()
{
// ref_x를 통해서 x의 값을 바꿀 수 있어서 불가능한 코드.
//const int x = 5;
//int &ref_x = x; // ref_x가 바뀔 수 있음.
// 둘다 const 사용하는 경우 가능
const int x = 5;
const int &ref_x = x;
// 참조 변수는 초기화를 해야 하므로 리터럴은 불가능
//int &ref_x = 3;
const int &ref_x = 3; // const 사용 시 가능
int a = 1;
doSomething(a);
doSomething(1);
doSomething(a + 3);
}
첫 번째에 const int x = 5;로 선언하고 int &ref_x = x;는 이전 강의에서 언급했듯이, ref_x가 x와 동일한 메모리 공간을 가리키고 있는 상황이기 때문에 ref_x를 통해서 x를 바꿀 수 있어서 안된다는 얘기를 했었다.
두 번째로 x와 참조 변수 모두 const를 사용하는 경우는 문제없이 작동한다.
세 번째로 이 내용 또한 앞에서 언급된 내용인데, 참조 변수는 반드시 초기화를 해야 하기 때문에 리터럴을 사용하면 불가능하다.
그런데 네 번째에 const int &ref_x = 3;를 하는 경우는 가능하다.
강의에서는 이유를 설명해주질 않으셔서 제미나이를 활용해서 물어보니 이유가 다음과 같다고 한다.
일반적인 참조 변수의 경우, 이미 존재하는 변수의 별명이라고 하는 참조의 본질에 따라 메모리 공간을 사용하지 않는 리터럴은 불가능한데,
const 키워드를 사용하면 숫자 1을 저장할 수 있는 임시적인 메모리 공간을 생성하고, ref는 이 메모리 공간을 참조하게 된다. 숫자 1은 변경이 불가능한 값이고 ref도 const로 인해 변경 불가능하기 때문에 둘 다 불변성을 가지고 있어서 가능한 것이다.
마지막으로 함수의 파라미터로 const int &a를 사용하게 되면, 앞에서 설명했듯이 const의 성질에 의해서 일반 변수뿐 아니라 1과 같은 숫자를 직접적으로 사용할 수 있게 된다.
Chapter 6.16 포인터와 참조의 멤버 선택
이번 챕터에서는 참조를 사용할 때의 멤버 선택과, 포인터를 사용할 때의 멤버 선택에 대해서 다룬다.
예제 코드 1
#include <iostream>
struct Person
{
int age;
double weight;
};
using namespace std;
int main()
{
Person person;
person.age = 5; // . 은 멤버 selection operator. 멤버를 선택하게 해주는 연산자.
person.weight = 30;
Person &ref = person;
ref.age = 15;
ref.weight = 70;
Person *ptr = &person;
ptr->age = 8;
ptr->weight = 45;
(*ptr).age = 20; // *을 붙이는 것보다 .이 우선순위가 높아서 괄호를 쳐야함. 보통 이렇게 쓰지 않음.
Person &ref2 = *ptr;
ref2.age = 45;
cout << &person << endl;
cout << &ref2 << endl;
}
이번 코드에서는 레퍼런스 변수와 포인터를 이용해서 구조체의 내부 멤버 변수를 선택하는 내용들로 이루어져 있다.
Person 타입의 person이라는 변수의 내부 멤버인 age, weight에 접근하는 일반적인 방식은 person.age와 person.weight이다.
Person 타입인 참조 변수를 활용하는 경우도 동일한 방법으로. 연산자를 이용하면 된다.
그런데 person의 주소를 가리키는 포인터 변수를 사용할 땐. 연산자가 아니라 -> 연산자를 활용해야 한다.
혹은 역참조를 이용한 후 . 연산자를 사용하는 방식인 (*ptr). age로 접근하는 방법도 있다.
마지막으로 참조 변수 &ref2와 *ptr를 연결하면 ref2 = ptr = person으로 연결되어 person의 age에 접근하는 방법도 있다.
강의에서는 따로 다루진 않았어서, 제미나이한테 물어보니 다음과 같은 차이가 있다고 한다.
점 연산자로 접근하는 경우는 객체 자체로 멤버에 접근할 때 사용하는 것이고, 화살표 연산자로 접근하는 경우는 포인터를 통해 객체의 멤버에 접근할 때 사용한다고 한다.
그리고 ptr -> age로 하는 방식이 사실상 (*ptr). age라는 것을 알 수 있었고, *ptr는 결국 메모리 주소에 있는 실제 객체를 역참조 하는 방식이기 때문에, *ptr로 객체로 간 다음 .age를 통해서 멤버에 접근할 수 있는 것이다.
Chapter 6.17 C++ 11 For-each 반복문
이번 챕터에서는 modern C++ 기능 중 하나인 for-each 반복문을 사용하는 방법에 대해서 배운다.
예제 코드 1
#include <iostream>
int main()
{
int fibonacci[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
for (const auto number : fibonacci)
{
std::cout << number << std::endl;
}
std::cout << std::endl;
}
int형 array인 finobancci를 정의하고, for (const auto number : fibonacci)로 작성하면 fibonacci라는 array에 정의된 각 요소들이 for문을 돌아가면서 하나씩 튀어나오게 된다.
따라서 위와 같은 코드를 작성해서 돌리면 array 내에 있는 요소들을 모두 출력해 볼 수 있다.
예제 코드 2
#include <iostream>
int main()
{
int fibonacci[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
// 함수에서 파라미터로 쓸 때와 동일, 참조 안 쓰면 실제 값이 변하지는 않음.
for (auto &number : fibonacci)
{
number *= 10;
}
for (const auto number : fibonacci)
{
std::cout << number << std::endl;
}
std::cout << std::endl;
int max_number = std::numeric_limits<int>::lowest();
for (const auto n : fibonacci)
{
max_number = std::max(n, max_number);
}
std::cout << max_number << std::endl;
}
위에서 언급한 대로 for-each문을 활용하되, for문을 돌면서 number에 10씩 곱해주는 코드를 추가했다.
for-each문은 함수에서 파라미터로 쓸 때와 동일해서, &number가 아닌 number로만 받으면 실제 array 내 값이 변하지는 않는다. 무조건 참조 변수로 해서 받아야만 실제 array에서의 값이 바뀐다.
배열의 가장 큰 요소를 찾을 때 for-each문을 사용하면서 std::max를 이용하게 되면 손쉽게 찾을 수 있다.
추가로, for-each문은 배열을 동적 할당하는 경우 사용할 수 없다고 한다.
그래서 C++ 상에서 동적으로 크기가 조절되는 배열과 같은 기능을 사용하려면 std::vector를 이용한다고 한다.
std::vector는 후속 강의들에서 다룬다고 한다.
이와 관련해서 제미나이에게 추가로 물어보니, 다음과 같은 답변을 들을 수 있었다.
int* dynamicArray = new int [5];와 같은 방식으로 int 5개 크기의 배열을 동적으로 할당하고, 배열에 값을 할당한 다음, for-each 방식으로 for (int value : dynamicArray)로 접근하게 되면 컴파일 에러가 발생하게 된다.
배열은 크기가 이미 정해져 있으니 괜찮은데, 동적 할당된 배열은 포인터일 뿐이니까 문제가 없다는 게 납득이 되었다.
Chapter 6.18 보이드 포인터
이번 챕터에서는 보이드 포인터에 대해서 알아본다.
예제 코드 1
#include <iostream>
using namespace std;
int main()
{
int i = 5;
float f = 3.0;
char c = 'a';
void *ptr = nullptr;
ptr = &i;
ptr = &f;
cout << &f << " " << ptr << endl;
cout << *static_cast<float*>(ptr) << endl;
// int형 pointer를 사용하는 경우, 포인터 연산을 사용할 수 있음.
int *ptr_i = &i;
cout << ptr_i << endl;
cout << ptr_i + 1 << endl;
}
보이드 포인터는 자료형을 void로 사용하는 포인터이다.
그렇다 보니, 기존에 특정 자료형으로 정해진 포인터와는 달리, 여러 가지 자료형 변수의 주소를 가리키는 포인터로 사용할 수 있다.
위 예제 코드에서 보듯이, int형 변수인 i에도 포인터를 사용할 수 있고, float형 변수인 f에도 포인터를 사용할 수 있다.
그런데, 보이드 포인터는 포인터 자체의 자료형이 없으므로 역참조를 할 때 어떤 자료형으로 표현해 주길 원하는지 알 수 없다.
따라서, void pointer를 사용할 때 역참조를 사용하려면 강제로 자료형을 명시해서 casting을 해줘야 하고, 복잡한 방식으로 역참조를 진행하게 된다.
그리고 또 하나의 차이점이 있다면, 특정 자료형의 pointer를 사용하는 경우 포인터 연산을 사용할 수 있지만 void 포인터는 포인터 연산을 했을 때 몇 byte씩 움직여야 되는지 알 수 없으므로 사용할 수 없다.
현실적으로 void pointer를 사용할 일이 많지는 않을 것이라고 하셨지만, '주소'라는 포인터의 특징을 이해하는 데는 도움이 되는 측면이 있다고 한다.
Chapter 6.19 다중 포인터와 동적 다차원 배열
이번 챕터에서는 다중 포인터와 동적으로 다차원 배열을 선언하는 방법에 대해서 다룬다.
예제 코드 1
#include <iostream>
using namespace std;
int main()
{
int *ptr = nullptr;
int **ptrptr = nullptr;
int value = 5;
ptr = &value;
ptrptr = &ptr;
// &value, ptr의 역참조, ptr(포인터의 주소)
cout << ptr << " " << *ptr << " " << &ptr << endl;
// ptr의 주소, &value, ptrptr의 주소
cout << ptrptr << " " << *ptrptr << " " << &ptrptr << endl;
// value의 값
cout << **ptrptr << endl;
}
지금까지는 포인터를 1개만 사용했지만, 이중 포인터를 사용하는 예제를 다뤄본다.
ptr이라는 포인터 변수는 현재 value라는 변수의 주소를 가리키도록 설정해 두었고, ptrptr이라는 이중 포인터 변수는 ptr의 주소를 가리키도록 했다.
줄줄이 사탕처럼 ptrptr -> ptr -> value의 구조라고 보면 될 것 같다.
그래서 ptr을 출력하면, value의 주소를 얻게 되고,
*ptr을 출력하면 value의 값인 5가 출력되고,
&ptr을 출력하면 ptr의 주소가 출력된다.
ptrptr은 ptr의 주소를 가리키고 있으니, &ptr와 같은 값을 얻는다.
*ptrptr는 ptr이라고 볼 수 있고, 이는 &value와 같아서 value의 주소를 얻는다.
&ptrptr는 ptrptr 포인터의 주소를 나타낸다.
**ptrptr는 역참조를 두 번 하는 것이라서, *(*ptrptr) -> *(ptr) -> value의 값을 얻게 되어 5가 된다.
예제 코드 2
#include <iostream>
using namespace std;
int main()
{
const int row = 3;
const int col = 5;
const int s2da[row][col] =
{
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
{11, 12, 13, 14, 15}
};
int **matrix = new int*[row];
for (int r = 0; r < row; ++r)
{
matrix[r] = new int[col];
}
for (int r = 0; r < row; ++r)
{
for (int c = 0; c < col; ++c)
{
matrix[r][c] = s2da[r][c];
}
}
for (int r = 0; r < row; ++r)
{
for (int c = 0; c < col; ++c)
cout << matrix[r][c] << " ";
cout << endl;
}
// delete
for (int r = 0; r < row; ++r)
{
delete[] matrix[r];
}
delete[] matrix;
}
다음은 이중 포인터 + 동적 할당을 하는 예제 코드이다.
행과 열을 const int로 정의하고, 2차원 배열을 먼저 선언해 준다.
2차원 배열을 동적 할당할 수 있도록 이중 포인터 matrix를 정의하고, row 크기만큼만 new로 정의해 준다.
개념적으로 생각해 보면, 2차원 배열은 col 크기 짜리의 1차원 배열이 row 개수만큼 있는 것이기 때문에, 이중 포인터 matrix는 row 크기만큼만 정의해 주면 된다.
그러고 나서 for문을 활용해서, matrix[r]에 col 크기의 int array를 new로 만들어준다.
2중 for문을 사용해 matrix의 [r][c] 위치에 s2da 배열의 [r][c]의 값을 전달해 주는 방식으로 matrix를 채워 넣는다.
matrix[r][c]을 이용해서 출력해 보고, delete[]를 해줄 때는 matrix의 row 단위로 처리해 주고 마지막에 matrix 전부를 delete 해준다.
예제 코드 3
#include <iostream>
using namespace std;
int main()
{
const int row = 3;
const int col = 5;
const int s2da[row][col] =
{
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
{11, 12, 13, 14, 15}
};
// 1차원 array를 2차원인 것 처럼 사용해야 함
int *matrix = new int[row*col];
for (int r = 0; r < row; ++r)
{
for (int c = 0; c < col; ++c)
{
matrix[c + col * r] = s2da[r][c];
}
}
for (int r = 0; r < row; ++r)
{
for (int c = 0; c < col; ++c)
cout << matrix[c + col * r] << " ";
cout << endl;
}
}
이번 예제는 이중 포인터를 사용하지 않고, 1차원 array를 동적 할당으로 만들어준 다음 마치 2차원인 것처럼 처리하는 예제이다.
matrix라는 이름의 int type의 array를 동적 할당 해주고, 그 대신 크기는 row*col 만큼 해준다.
다음으로 2중 for문을 사용해서 matrix의 각 위치에 값을 전달해 주는데, 2차원 indexing이 불가능하기 때문에 1차원으로 바꾸어서 사용해야 한다.
현재 코드상에서는 c + col * r와 같은 방식으로 index를 잡아주고 있는데, 이는 다음과 같이 생각하면 된다.
지금 예제에서 보면 [1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]로 5개 크기의 array가 3개가 있는 셈이다.
이를 2차원이 아니라 일렬로 줄 세워서 추가해 준다고 보면 된다.
즉, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]로 추가해줘야 한다는 의미이다.
이렇게 처리하려면, 우선 0번째 row에서 0번째 col부터 마지막까지 쭉 추가를 해준다. 실제 예시에서는 [1, 2, 3, 4, 5]를 추가해 주는 것이다.
이 부분이 바로 c가 된다.
다음으로 1번째 row에서 0번째 col부터 마지막까지 쭉 추가를 해준다. 실제 예시에서는 [6, 7, 8, 9, 10]를 추가해 주는 것이다.
이 부분은 c + col * 1이 된다. 즉, col 크기만큼 한번 추가해 줬으니, c + col이 되는 것이다.
이후로 col이 계속 추가되니, col * r를 추가해 주는 개념이라고 보면 된다.
Chapter 6.20 std::array 소개
이번 챕터에서는 C style array의 단점을 보완한 std::array에 대해서 알아본다.
예제 코드 1
#include <iostream>
#include <array>
using namespace std;
void printLength(array<int, 5> my_arr)
{
cout << my_arr.size() << endl;
}
int main()
{
//int c_array[5] = { 1, 2, 3, 4, 5 };
array<int, 5> my_arr = { 1, 2, 3, 4, 5 };
my_arr = { 0, 1, 2, 3, 4 };
my_arr = { 0, 1, 2, };
//cout << my_arr[10] << endl; // index가 크기를 넘는지 아닌지 체크 안하고 그냥 작동함
//cout << my_arr.at(10) << endl; // index가 크기를 넘는지 체크해보고 문제가 발생하면 예외처리 발생. 조금 느림.
cout << my_arr.size() << endl;
printLength(my_arr);
for (auto &element : my_arr)
{
cout << element << " ";
}
cout << endl;
my_arr = { 1, 21, 3, 40, 5 };
//std::sort(my_arr.begin(), my_arr.end()); // 오름차순
std::sort(my_arr.rbegin(), my_arr.rend()); // 내림차순
for (auto &element : my_arr)
{
cout << element << " ";
}
cout << endl;
}
기존 c style array는 int c_array[5] = {1, 2, 3, 4, 5}; 와 같은 방식으로 정의했었다.
그러나 std::array를 사용하는 경우에는 std::array<int, 5> my_array = {1, 2, 3, 4, 5};와 같은 방식으로 정의한다.
기존 array와 std::array와의 차이를 보자면, 이전 강의에서도 다뤘듯이 array는 함수의 파라미터로 전달할 때 포인터로 처리된다.
만약 void doSomething(int arr[]) 이런 식으로 int형 array를 받게 되었을 때, arr는 포인터이기 때문에 sizeof를 사용하더라도 int형 포인터 변수의 size를 알려주는 것이지 배열의 사이즈를 알려주는 게 아니다.
이는 앞의 강의들에서 언급되었듯이 배열이 커졌을 때 이를 전부 복사하기에는 부담스럽기 때문이다.
하지만 std::array는 기본적으로 자료형과 크기가 픽스된 상태이기 때문에, 함수의 파라미터로 전달하더라도 이에 대한 정보를 가지고 있게 된다.
C style array에서는 없었던 .at 이라는 기능이 있는데, 이는 범위 검사 기능을 가지고 있어 out of index 에러가 발생했을 때 이를 미리 컴파일 단계에서 체크해서 에러를 발생시킨다.
그리고 std::array는 .size()를 사용할 수 있고, 이는 array의 사이즈를 출력해 준다.
void printLength 함수에서 my_arr.size()를 했을 때 결과가 나오는 것을 통해 std::array는 함수의 파라미터로 사용되었을 때도 size에 대한 정보를 가지고 있음을 확인할 수 있다.
최근 수업에서 다루었던 for-each 문도 std::array에 대해서 사용 가능하다는 점.
std::sort를 사용하면 정렬이 가능하고, begin()과 end()를 사용하면 오름차순, rbegin()과 rend()를 사용하면 내림차순이다.
Chapter 6.21 std::vector
이번 챕터에서는 array를 동적할당으로 쉽게 사용할 수 있도록 해주는 std::vector에 대해서 다룬다.
예제 코드 1
#include <iostream>
#include <vector>
using namespace std;
int main()
{
// 동적 할당
std::vector<int> array;
std::vector<int> array2 = { 1, 2, 3, 4, 5 };
cout << array2.size() << endl;
std::vector<int> array3 = { 1, 2, 3 };
cout << array3.size() << endl;
std::vector<int> array4 { 1, 2, 3 };
cout << array4.size() << endl;
}
이전에 array를 동적 할당 하려면 int *arr = new int[5];와 같은 방식으로 만들어주었는데, std::vector를 사용하면 더욱 쉽게 동적 할당을 할 수 있다.
단순히 이름만 정의해도 되고, array2처럼 요소들을 같이 정의해도 된다.
std::vector는 항상 길이에 대한 정보를 가지고 있기 때문에, 함수에 파라미터로 전달했을 때 길이에 대한 정보를 잃게 되는 일이 없다.
std::vector는 .size()를 이용하면 배열의 사이즈를 바로 확인할 수 있다.
예제 코드 2
#include <iostream>
#include <vector>
using namespace std;
int main()
{
// 메모리 관리 유용하게.
// 동적 메모리 할당의 장점을 충분히 활용할 수 있다.
int *my_arr = new int[5];
vector<int> arr = { 1 ,2 ,3 ,4 ,5 };
arr.resize(10); // 나머지는 0으로 채우고 resize 진행
for (auto &itr : arr)
{
cout << itr << " ";
}
cout << endl;
cout << arr.size() << endl;
cout << arr[1] << endl;
cout << arr.at(1) << endl;
delete[] my_arr;
}
이전 강의에서 얘기했듯이, 동적 할당은 반드시 delete 명령어를 통해 메모리에서 해제해 주는 작업을 해줘야 한다.
근데 변수가 적으면 괜찮지만, 변수가 많을 때 이를 일일이 다 delete로 관리하는 일은 쉽지 않을 것이다.
std::vector는 delete로 처리해주지 않아도 알아서 되기 때문에 훨씬 편하다.
for-each 문도 사용이 가능하고, resize라는 기능도 있어서 배열의 사이즈를 조절할 수 있다.
여기까지 Chapter 6을 모두 다루었다.
워낙 챕터가 길다 보니까, 정리하는데 긴 시간이 걸렸다.
확실히 이번 챕터부터 내용이 조금 어렵고 중요하다는 생각이 든다.
Chapter 7로 찾아오겠습니다.
'C++ > 따라하며 배우는 C++' 카테고리의 다른 글
홍정모의 따라하며 배우는 C++ - Chapter 6(6.1 ~ 6.10, 전반부) (0) | 2025.05.25 |
---|---|
홍정모의 따라하며 배우는 C++ - Chapter 5 (1) | 2025.05.11 |
홍정모의 따라하며 배우는 C++ - Chapter 4 (0) | 2025.03.16 |
홍정모의 따라하며 배우는 C++ - Chapter 3 (0) | 2025.02.09 |
C++ 공부 관련 정리글을 업로드 해보려고 합니다. (0) | 2025.02.02 |