본문 바로가기

내일배움캠프

내일배움캠프 언리얼트랙 12일차

스트레칭을 안하면 허리랑 목이 굳어버릴거에요~

오늘의 학습

- C++ 강의


오늘은 C++강의를 처음부터 다시 듣기 시작했습니다! 포인터와 클래스에 대한 이해가 완벽하지 않아서... 강의 듣고, 강의 자료의 코드 따라해보면서 포인터와 클래스 마스터 해볼게요!

강의 들으면서 내용 정리는 옵시디언을 사용하는중이에요. 내용 정리에는 옵시디언이 쓰기 편하고 보기도 좋더라구요.

 

 

포인터
변수의 주소를 담는 변수

int* p*
p는 정수를 가리키는 포인터로써 int변수의 메모리 주소를 저장.

일반 변수의 경우, 대입연산(=)을 하면 변수에 있는 값이 그대로 복사됨.
하나의 변수를 다른 변수에 대입하면 새로운 메모리 공간에 동일한 값이 복제됨. 따라서 복사 이후에 두 변수는 서로 독립적인 공간을 가지게 되어 한 쪽 값을 변경해도 다른 쪽에는 영향이 없다.

예를 들어, A=3, B=4 일 때, A=B 이후 A=7을 하게 되어도 B의 값은 그대로이다. (독립된 메모리 공간이기 때문.)

복사는 비용이 있다. 따라서 포인터를 활용해 변수의 주소를 가리켜서 동일한 데이터에 접근할 수 있도록 한다.

포인터 변수의 연산
포인터의 모든 연산은 주소값과 관련되어있다.

변수의 주소값을 담을 수 있다.
담고 있는 주소값에 해당되는 메모리에 있는 값을 읽거나 수정할 수 있다.

A=&B를 하게 되면 A에 B의 주소값이 들어간다.

#include <iostream>
using namespace std;

int main() {
    int a = 10;
    int* p = &a; // 변수 a의 주소를 포인터 p에 저장

    cout << "변수 a의 값: " << a << endl;
    cout << "변수 a의 주소: " << &a << endl;
    cout << "포인터 p의 값(저장된 주소): " << p << endl;

    return 0;
}

/*
출력 결과:
변수 a의 값: 10
변수 a의 주소: 0x61ff08 (주소는 실행할 때마다 다를 수 있음)
포인터 p의 값(저장된 주소): 0x61ff08
*/
#include <iostream>
using namespace std;

int main() {
    int arr[3] = {10, 20, 30};
    int* p = arr; // 배열의 시작 주소를 포인터에 저장

    cout << "p가 가리키는 값: " << *p << endl;
    cout << "p+1이 가리키는 값: " << *(p + 1) << endl;
    cout << "p+2이 가리키는 값: " << *(p + 2) << endl;

    return 0;
}

/*
출력 결과:
p가 가리키는 값: 10
p+1이 가리키는 값: 20
p+2이 가리키는 값: 30
*/

포인터 변수의 구성 요소
변수의 시작 주소는 알았는데 변수의 크기가 모두 다르니 끝도 알아야 한다.
포인터는 주소 값 뿐만 아니라 가리키는 변수의 타입도 필요하다.

변수의 시작 주소
변수의 타입 (변수의 크기를 알기 위해 필요)

C++에서는 이를 위해 타입 정보를 포함한 포인터 변수를 사용한다.
포인터 변수는 선언 시 데이터 형 뒤에 *를 붙여 포인터임을 표시한다.

int* ptr
ptr은 정수를 가리키는 포인터가 됨.
변수의 시작 주소 값을 담고, 해당 변수 타입이 정수라는 걸 알 수 있으므로 제대로 값을 읽어올 수 있다.

포인터 변수의 타입과 크기기

#include <iostream>
using namespace std;

int main() {
    int x = 3;
    char y = 'A';

    int* ptr1 = &x;  // 정수형 변수 x의 주소 저장
    char* ptr2 = &y; // 문자형 변수 y의 주소 저장

    cout << "ptr1이 가리키는 값: " << *ptr1 << endl;
    cout << "ptr2가 가리키는 값: " << *ptr2 << endl;

    return 0;
}

/*
출력 결과:
ptr1이 가리키는 값: 3
ptr2가 가리키는 값: A
*/

포인터를 이용한 주소 확인

#include <iostream>
using namespace std;

int main() {
    int x = 3;
    char y = 'A';

    int* ptr1 = &x;
    char* ptr2 = &y;

    cout << "x의 주소: " << &x << ", ptr1: " << ptr1 << endl;
    cout << "y의 주소: " << (void*)&y << ", ptr2: " << (void*)ptr2 << endl;

    return 0;
}

/*
출력 결과 (주소는 실행할 때마다 다름):
x의 주소: 0x61ff00, ptr1: 0x61ff00
y의 주소: 0x61ff04, ptr2: 0x61ff04
*/

포인터를 이용한 값 변경

#include <iostream>
using namespace std;

int main() {
    int a = 10;
    int* p = &a; // a의 주소 저장

    cout << "변경 전 a: " << a << endl;

    *p = 20; // 포인터를 이용하여 값 변경

    cout << "변경 후 a: " << a << endl;

    return 0;
}

/*
출력 결과:
변경 전 a: 10
변경 후 a: 20
*/

포인터 변수의 역참조
포인터를 활용하려면 해당 주소에 있는 실제 값을 읽고 수정할 수 있어야 함.
포인터는 주소를 다루는 특성 때문에 산술 연산 역시 주소를 제어하는 방식으로 동작함.

이를 위해 역참조 연산자(*)를 사용한다.
A라는 포인터 변수가 있다면,
A를 출력하면 주소값이 나오고
*A를 출력하면 해당 메모리에 있는 값이 나온다.

배열 이름의 의미
배열도 변수이다. 기본적인 변수의 특성을 가진다.

배열 이름은 배열의 시작 주소를 가진다.
값을 저장할 수 있다.

다른점이 있다면, 일반 변수는 값을 하나 담는 반면, 배열은 여러 개 담을 수 있다. 따라서 배열만이 갖는 추가적인 특성이 있다.

인덱스를 통한 임의 접근이 가능한 이유는 배열은 메모리가 연속적으로 할당되기 때문.
임의 접근이 가능하려면 배열 이름에 저장된, 배열의 시작 주소를 알아야한다. 이러한 이유로 배열 자체가 담고 있는 시자 주소를 변경할 수 없다.
배열 이름은 주소값을 담고 있기 때문에, 기존 포인터와 마찬가지로 *연산자를 활용해서 해당 주소에 있는 값을 확인할 수 있다.
값을 읽는 것뿐 아니라, 기존 포인터와 마찬가지로 *연산자를 활용해서 값을 넣을 수 있다.

배열과 포인터의 차이

배열의 이름은 사용될 때 대부분 포인터로 암시적 형 변환되어 동작.
int arr[4];가 있을 때, arr은 배열 전체를 의미.
하지만 식이나 인자로 사용되면 int*형으로 변환되어 배열의 첫 번째 원소 주소로 해석.

배열 이름은 주소값을 담고있지만, 이 주소값 대신 다른 주소값을 할당할 수 없다.

배열 이름이 담고 있는 주소값은 특별하기 때문. 이 주소값이 없으면 기준점이 없어지므로 임의 접근이 불가능.
char arr[4]; 라는 배열이 있다 하자.
이 배열에 다른 주소값을 넣게되면 에러가 발생한다.
char y = 'A' arr = &y; -> error!!

변수의 크기가 다르다.

포인터 변수의 크기는 타입과 무관하게 운영체제에서 관리하는 메모리 주소의 크기이다.
하지만 배열의 크기는 배열 원소 타입의 크기에 개수를 곱한 것.

즉, 여러 개의 데이터를 관리하기 위해 포인터가 가지고 있는 연산이 제공되는 변수.

포인터 배열과 배열 포인터

포인터 배열은 포인터를 원소로 갖는 배열.
ex) int* ptrArr[4];는 크기가 4이고, 각 원소가 int*인 배열이다.

배열 포인터는 배열 전체를 가리키는 포인터.
즉, 단일 변수가 아닌 배열 통째로 가리키는 변수.
보통 다차원 배열을 제어할 때 많이 사용.

포인터 배열

#include <iostream>
using namespace std;

// 포인터 배열: 포인터를 원소로 갖는 배열
int main() {
    int a = 10, b = 20, c = 30;
    int* ptrArr[3] = { &a, &b, &c }; // 포인터 배열 선언 및 초기화

    // 포인터 배열을 이용하여 값 출력
    cout << "*ptrArr[0]: " << *ptrArr[0] << endl; // 10
    cout << "*ptrArr[1]: " << *ptrArr[1] << endl; // 20
    cout << "*ptrArr[2]: " << *ptrArr[2] << endl; // 30

    return 0;
}

/*
출력 결과:
*ptrArr[0]: 10
*ptrArr[1]: 20
*ptrArr[2]: 30
*/

배열 포인터

#include <iostream>
using namespace std;

// 배열 포인터: 배열 전체를 가리키는 포인터
int main() {
    int arr[3] = { 100, 200, 300 };
    int (*ptr)[3] = &arr; // 배열 포인터 선언

    // 배열 포인터를 이용하여 배열 요소 접근
    cout << "(*ptr)[0]: " << (*ptr)[0] << endl; // 100
    cout << "(*ptr)[1]: " << (*ptr)[1] << endl; // 200
    cout << "(*ptr)[2]: " << (*ptr)[2] << endl; // 300

    return 0;
}

/*
출력 결과:
(*ptr)[0]: 100
(*ptr)[1]: 200
(*ptr)[2]: 300
*/

포인터 배열 vs 배열 포인터

#include <iostream>
using namespace std;

// 포인터 배열과 배열 포인터의 차이점 확인
int main() {
    int x = 1, y = 2, z = 3;
    int* ptrArr[3] = { &x, &y, &z }; // 포인터 배열 (각 원소가 int* 타입)
    
    int arr[3] = { 10, 20, 30 };
    int (*ptr)[3] = &arr; // 배열 포인터 (배열 전체를 가리킴)

    // 포인터 배열을 통한 접근
    cout << "*ptrArr[0]: " << *ptrArr[0] << endl; // 1
    cout << "*ptrArr[1]: " << *ptrArr[1] << endl; // 2
    cout << "*ptrArr[2]: " << *ptrArr[2] << endl; // 3

    // 배열 포인터를 통한 접근
    cout << "(*ptr)[0]: " << (*ptr)[0] << endl; // 10
    cout << "(*ptr)[1]: " << (*ptr)[1] << endl; // 20
    cout << "(*ptr)[2]: " << (*ptr)[2] << endl; // 30

    return 0;
}

/*
출력 결과:
*ptrArr[0]: 1
*ptrArr[1]: 2
*ptrArr[2]: 3
(*ptr)[0]: 10
(*ptr)[1]: 20
(*ptr)[2]: 30
*/

포인터 연산
포인터는 주소값을 담기 때문에
산술 연산 시, 일반적인 수치 연산이 아닌 메모리 주소의 이동으로 해석됨.

int* ptr 에서
ptr+1을 하게 되면, ptr이 감고 있는 주소값에 대한 연산이 수행됨.
포인터의 타입에 따라, 해당 타입 변수의 크기만큼 담고 있는 주소를 증가시킴.

(*ptr) + 1을 하게 되면, ptr이 가리키는 변수의 값을 1 증가시킴.

*(ptr + 1)은 ptr[1]과 동일.
실제 C/C++에서 배열 인덱스 연산자 []는 내부적으로 포인터 연산을 통해 구현되어 있음.
즉, ptr주소에서 한 단위 이동한 후 해당 값을 출력.

포인터 연산의 경우 +, - 가능하다.
하지만 *와 /는 불가능하다.

레퍼런스
포인터를 사용하면 주소값을 직접 다루어야 하므로 복잡해질 수 있다.
이 문제를 완화하기 위해 C++ 에서는 변수에 또 다른 이름을 부여하는 '레퍼런스'문법을 도입했다.

레퍼런스는 일반 변수와 거의 동일하게 사용할 수 있다.
그러나 내부적으로는 해당 변수를 직접 가리켜 주는 역할을 한다.

레퍼런스는 특정 변수에 대한 별명을 부여하는 것
한 번 특정 변수의 레퍼런스를 연결하면, 이후로는 마치 그 변수가 두 개의 이름을 갖는 것과 같다.
선언 방법은 데이터형 뒤에 &를 붙이는 것이다.

ex) int& ref = var; 처럼 사용할 수 있다.
이렇게 하면 ref의 값 변경 시 var의 값도 변경된다.

레퍼런스는 선언과 동시에 초기화해야한다.

#include <iostream>
using namespace std;

// 레퍼런스를 활용하여 변수에 별명을 부여하는 예제
int main() {
    int var = 10;
    int& ref = var; // var의 레퍼런스 선언

    cout << "초기 값:" << endl;
    cout << "var: " << var << endl; // 10
    cout << "ref: " << ref << endl; // 10

    ref = 20; // ref를 변경하면 var도 변경됨

    cout << "ref 값을 변경한 후:" << endl;
    cout << "var: " << var << endl; // 20
    cout << "ref: " << ref << endl; // 20

    return 0;
}

/*
출력 결과:
초기 값:
var: 10
ref: 10
ref 값을 변경한 후:
var: 20
ref: 20
*/

포인터와 레퍼런스의 차이점

포인터와 레퍼런스는 다른 변수를 제어한다는 점에서 유사하지만 몇 가지 중요한 차이가 있다.

선언과 초기화 시점이 다르다.

포인터는 선언 후, 나중에 =연산자를 통해 가리킬 대상 변경 가능.
레퍼런스는 선언과 동시에 초기화를 해야 하며, 초기화 이후에는 다른 대상에 재연결이 불가능.

레퍼런스틑 항상 다른 변수와 연결되어 있기 때문에 NULL이라는게 없다.

반면 포인터는 유효한 대상이 없음을 나타내기 위해 NULL 혹은 nullptr을 가질 수 있다.

간접 참조 문법의 유무이다.

포인터는 주소값을 담으므로 접근할 때는 * 연산을 사용하고 주소를 가져올 때는 &연산을 사용한다.
레퍼런스는 변수 자체의 별명이므로 일반 변수와 연산하는 방법이 동일하다.

#include <iostream>
using namespace std;

// 포인터와 레퍼런스의 선언 및 초기화 차이
int main() {
    int a = 10, b = 20;

    // 포인터는 선언 후 나중에 다른 변수를 가리킬 수 있음
    int* ptr = &a; // 포인터 선언 및 초기화
    ptr = &b; // 포인터가 다른 변수를 가리킬 수 있음

    // 레퍼런스는 선언과 동시에 초기화해야 함
    int& ref = a;
    // ref = &b; // ❌ 오류! 레퍼런스는 다른 변수에 재할당할 수 없음

    cout << "포인터 사용:" << endl;
    cout << "*ptr: " << *ptr << endl; // 20 (포인터가 b를 가리키고 있음)

    cout << "레퍼런스 사용:" << endl;
    cout << "ref: " << ref << endl; // 10 (a를 가리키고 있음)

    return 0;
}

/*
출력 결과:
포인터 사용:
*ptr: 20
레퍼런스 사용:
ref: 10
*/
#include <iostream>
using namespace std;

// 포인터는 NULL을 가질 수 있지만, 레퍼런스는 반드시 변수와 연결되어야 함
int main() {
    int a = 42;
    int* ptr = nullptr; // 포인터는 nullptr이 가능
    ptr = &a; // 이후에 a를 가리키도록 설정 가능

    // int& ref; // ❌ 오류! 레퍼런스는 반드시 선언과 동시에 초기화해야 함
    int& ref = a; // 올바른 선언 방식

    cout << "포인터 사용:" << endl;
    cout << "ptr이 가리키는 값: " << *ptr << endl; // 42

    cout << "레퍼런스 사용:" << endl;
    cout << "ref: " << ref << endl; // 42

    return 0;
}

/*
출력 결과:
포인터 사용:
ptr이 가리키는 값: 42
레퍼런스 사용:
ref: 42
*/
#include <iostream>
using namespace std;

// 포인터와 레퍼런스의 간접 참조 문법 비교
int main() {
    int x = 5;
    int* ptr = &x;  // 포인터 선언
    int& ref = x;   // 레퍼런스 선언

    cout << "포인터 접근 방법:" << endl;
    cout << "x: " << x << endl;       // 5
    cout << "*ptr: " << *ptr << endl; // 5 (포인터를 통한 간접 참조)
    cout << "ptr: " << ptr << endl;   // x의 주소값

    cout << "레퍼런스 접근 방법:" << endl;
    cout << "ref: " << ref << endl;   // 5 (레퍼런스는 그냥 변수처럼 사용 가능)

    *ptr = 10; // 포인터를 사용하여 값 변경
    cout << "포인터로 변경 후 x: " << x << endl; // 10

    ref = 20; // 레퍼런스로 값 변경
    cout << "레퍼런스로 변경 후 x: " << x << endl; // 20

    return 0;
}

/*
출력 결과:
포인터 접근 방법:
x: 5
*ptr: 5
ptr: 0x... (메모리 주소)
레퍼런스 접근 방법:
ref: 5
포인터로 변경 후 x: 10
레퍼런스로 변경 후 x: 20
*/

상수 레퍼런스
레퍼런스에 상수 제약을 걸어서 읽기 전용으로 사용 가능.
상수 레퍼런스를 사용하면 값을 복사하지 않고도 기존 변수를 보호할 수 있다.

const int& cref = x; 하면 복사 과정 없이 x의 값을 읽을 수는 있지만 x값을 수정할 수는 없다.