7. 포인터, 배열, 참조

7.2 포인터

  • 어떤 타입 T에 대해 T* 는 'T를 가리키는 포인터' 타입이다. -> 즉, T* 타입의 변수는 T 타입의 객체가 저장된 메모리 주소를 보관할 수 있다.

7.2.1 void*

  • void* 는 '알지 못하는 타입의 객체를 가리키는 포인터' 이다.

  • 어떤 타입의 변수, 배열, 개체를 가리키는 모든 포인터를 void* 타입의 변수에 대입할 수 있지만,

  • 함수를 가리키는 포인터, 멤버를 가리키는 포인터는 그렇게 할 수 없다.

// void* 대입이 가능한 경우 
int a = 10;
double b = 5.5;

void* ptr1 = &a; // int* -> void* 변환
void* ptr2 = &b; // double* -> void* 변환

// void* 대입이 불가능한 경우 
// case 1 (함수 포인터)
void myFunction(int x) {
    // ...
}
void (*funcPtr)(int) = myFunction; // 함수 포인터

void* ptr = funcPtr; // 오류! 함수 포인터는 void*로 변환될 수 없음

// case 2 (멤버 포인터)
class MyClass {
public:
    int member;
    void memberFunction() {
        // ...
    }
};
int MyClass::*memberPtr = &MyClass::member; // 데이터 멤버 포인터
void (MyClass::*funcPtr)() = &MyClass::memberFunction; // 멤버 함수 포인터

void* ptr1 = memberPtr; // 오류! 데이터 멤버 포인터는 void*로 변환될 수 없음
void* ptr2 = funcPtr;   // 오류! 멤버 함수 포인터는 void*로 변환될 수 없음
  • void* 는 또 다른 void* 연산이 가능하고, void* 는 다른 타입으로 명시적으로 변환이 가능하지만, 그 외의 연산은 안전하지 않다. -> 실제로 어떤 종류의 객체를 가리키고 있는지 컴파일러가 알 수 없기 때문이다. -> 결과적으로 이 외의 연산을 사용하게 되면 컴파일 에러가 발생한다. -> void * 를 실제로 사용하기 위해서는 특정 타입을 가리키는 포인터로 명시적으로 바꿔줘야 한다. (캐스팅 사용)

7.2.2 nullptr

  • nullptr 은 객체를 가리키진 않은 포인터를 나타낸다.

  • 기존의 *타입의 변수가 아무것도 가리키지 않는 것을 표현할 때 NULL 을 대입해주고는 했는데, 해당 방식은 *타입이 아닌 타입의 대입에서도 사용이 가능했기 때문에 함수 호출 시 일어나는 혼란이 발생할 수 있었다.

  • nullptr 을 사용하면 코드의 가독성이 좀 더 향상된다.

7.3 배열

  • 배열 타입의 변수는 배열의 첫번째 주소를 가리킨다.

  • 배열의 범위를 벗어난 접근은 허용되지 않기에, 오류가 발생한다. -> 배열의 크기가 계속해서 변하는 배열이 필요한 경우라면 vector 와 같이 가변형 배열을 사용하는 것이 좋다.

  • 메모리 내에서 정해진 타입으로 이뤄진 연속된 객체로써 고정된 길이를 가지고 있다면 배열이 이상적인 해결책이다. -> 그 외의 요구 사항에서는 배열은 심각한 문제점을 지니고 있다. (길이 초과로 인한 재할당 등등 ..)

  • 배열이 힙 영역에 할당된다면 반드시 delete[] 를 사용해 해당 자원을 회수해야 한다.

7.3.1 배열 초기화

  • 다음과 같이 초기화 했을 시 초기화 식의 개수가 배열 크기에 못 미친다면 배열 원소에 타입의 기본값이 들어간다. char v1[3] = {'a', 'b'}; // 마지막 값은 0으로 초기화

  • 배열에 대해서는 복사연산이 제공되지 않고, 한 배열을 다른 배열로 초기화 할 수 없으며, 배열 대입도 가능하지 않다. int v2[8] = v1; // 배열을 복사할 수 없다! (연산자 오버로딩을 통해 가능하다) v2 = v1; // 배열 대입은 불가하다!

7.3.2 문자열 리터럴

  • 문자열 리터럴은 큰따옴표("") 로 둘러 싸여진 연속된 문자열이다.

  • 문자열 리터럴에는 보이는 것보다 한 개 더 많은 문자가 포함되어 있다. -> 문자 '\0' 으로 끝나기 때문이다.

  • 문자열 중간에 '\0' 이 들어가게 되면 컴파일러는 문자열이 종료되었다고 생각하고 이후 문자열은 읽지 않는다.

  • 문자열 리터럴 타입은 'const 문자가 여러 개 모인 배열' 이라고 말할 수 있다. -> "abcd"const char[5] 가 된다.

  • C++11 표준부터는 아래 코드는 컴파일 에러가 발생한다. ( 문자열 리터럴은 변경이 불가하다) -> 문자열 리터럴을 변경 불가로 만드는 것은 명확할 뿐만 아니라, 문자열 리터럴이 저장되고 접근 되는 방식의 구현에서 상당한 성능 최적화를 이룰 수 있게 해준다.

  • const 가 아닌, 배열에 문자를 배치한다면 수정이 가능하다.

7.4 배열을 가리키는 포인터

  • 배열의 이름은 첫번째 원소를 가리키는 포인터로 암시적으로 변환된다.

7.4.1 배열 탐색

  • 아래 두 코드의 성능 차이는 없다. (내부적으로 컴파일러가 동일한 코드를 만들어준다)

  • +, ++, -- 등의 산술 연산자를 사용할 경우 데이터 값의 연산이 아닌, 포인터 연산이 이루어진다. -> 복잡한 포인터 산술 연산은 대개 별로 필요하지 않으며, 피하는 편이 좋다.

7.5 포인터와 const

  • 변수를 const 로 선언하면 해당 유효 범위 안에서는 값이 변경되지 않도록 보장된다.

  • 포인터 타입에서 const 를 붙이는 위치에 따라서 의미가 달라진다.

7.7 참조자

  • 포인터 변수를 사용하는 따라 다음의 문제를 발생시킨다.

    • f(x) 에 비해 f(&x) 가 가독성이 떨어진다.

    • 포인터 변수는 nullptr 이 사용 될 가능성을 항상 대비해야 한다.

    • 연산자 오버로딩의 사용 편의가 떨어진다. -> + 연산자 오버로딩을 할 경우, &x+&y 보다는 x+y 로 사용하고 싶을 것이다.

  • 이를 해결하기 위한 매커니즘이 참조자(reference) 이다. -> 내부적으로는 참조자를 포인터로 변경해 사용한다.

  • 참조자는 다음과 같은 장점을 가지고 있다.

    • 참조자는 일반 변수와 동일한 문법으로 사용할 수 있다.

    • 참조자는 객체로 초기화되는데, 참조자는 항상 해당 객체를 참조한다.

    • null 참조자는 존재하지 않으며, 하나의 참조자는 하나의 객체를 참조한다고 가정할 수 있다.

7.7.1 lvalue 참조자

  • lvalue 참조자를 사용하려면 항상 초기화를 해야한다!

  • 포인터와 달리 참조자에는 연산자를 적용시킬 수 없다. -> 참조자는 주소를 참조하는 것이지, 직접적으로 주소를 변경할 수는 없다. -> 즉 참조자는 항상 자신이 처음에 참조하게 된 객체만을 참조한다는 것이다.

  • T& 에 대한 초기화 식은 T 타입의 좌변 값이어야 한다.

  • const T& 에 대한 초기화 식은 T 타입의 lvalue, rvalue 모두 가능하다. -> rvalue 인 경우 임시변수를 만들어준다.

7.7.2 rvalue 참조자

  • const 가 아닌 lvalue 참조자는 사용자가 값을 입력할 수 있는 객체(lvalue) 를 참조한다.

  • const lvalue 참조자는 변할 수 없는 상수를 참조한다.

  • rvalue 참조자는 임시변수(rvalue) 를 참조한다.

  • rvalue 참조자는 때때로 값비싼 복사 처리를 저렴한 비용으로 처리할 수 있다. (destructive read)

destructive read

  • C++에서 "destructive read(파괴적 읽기)"는 특정한 용어로 사용되지는 않습니다. 하지만 일반적으로 C++에서 데이터를 읽는 작업이 해당 데이터를 변경하거나 파괴하는 경우가 있을 수 있습니다.

  • 예를 들어, C++의 std::move 함수는 "move semantics(이동 의미론)"을 사용하여 객체를 이동하고 원본 객체의 상태를 변경합니다. 이러한 경우, 객체를 읽는 작업이 원본 객체의 상태를 변경하는 "destructive read"로 간주될 수 있습니다.

  • rvalue 참조자 목적

    • rvalue 참조자는 데이터 복사가 일어나는 상황에서 객체의 최적화에 필요한 destructive read 를 수행하기 위해서 사용된다.

  • lvalue 를 rvalue 로 캐스팅 하는 방법

    • static_cast 사용 : static_cast<T&&>(x);

    • move 함수 사용 : move(x);

Last updated