나무모에 미러 (일반/밝은 화면)
최근 수정 시각 : 2024-03-25 05:17:51

C++/가이드라인

파일:관련 문서 아이콘.svg   관련 문서: C++/문법
,
,
,
,
,

파일:상위 문서 아이콘.svg   상위 문서: C++
프로그래밍 언어 문법
{{{#!wiki style="margin: -16px -11px; word-break: keep-all"<colbgcolor=#0095c7><colcolor=#fff,#000> 언어 문법 C(포인터) · C++(자료형 · 특성 · 클래스 · 이름공간 · 상수 표현식) · C# · Java · Python · Kotlin · MATLAB · SQL · PHP · JavaScript
마크업 문법 HTML · CSS
개념과 용어 함수 · 인라인 함수 · 고차 함수 · 람다식 · 리터럴 · size_t · 상속 · 예외 · 조건문 · 참조에 의한 호출 · eval
기타 == · === · NaN · null · undefined · 모나드 · 배커스-나우르 표기법
프로그래밍 언어 예제 · 목록 · 분류 }}}

1. 개요
1.1. main 소스 파일
2. include
2.1. 전통적인 구조
2.1.1. 작성 방법
2.1.1.1. 선언부2.1.1.2. 구현부2.1.1.3. 실행부
2.2. 이름공간을 사용한 구조2.3. Inlining 정의
2.3.1. 처리기 & static2.3.2. 익명 이름공간
3. import C++20 4. 메모리 할당
4.1. 정적 할당4.2. 자동 할당4.3. 동적 할당4.4. 스마트 포인터 (Smart Pointer)4.5. 참조 횟수 세기 포인터 (std::shared_ptr)4.6. 배타적 소유 포인터 (std::unique_ptr)

1. 개요

C++의 코드 작성 방법을 간단하게 설명하는 문서. 곧 C++ 문서에서 연계되는 내용을 담고 있다. C++의 첫 시작을 어떻게 해야할지 보여주는 문서라고 할 수 있다. 이 문서는 헤더 구조를 벗어나 새롭게 C++ 코드를 작성하는 방법을 소개한다. 완고할 것 같았던 C++에도 변화의 바람이 불었으니, C++20에서 모듈의 도입으로 언어의 근본부터 엄청난 변화가 일어났기 때문에 전과 이후의 코드를 완전히 다른 방식으로 작성할 수 있다. 이전의 헤더를 사용하는 방식은 여전히 남아있으나, C언어의 방식 및 연결고리를 하나씩 대체해가고 있으며, 헤더도 과거의 유산이 될 예정이다. 그러나 C++20의 모듈은 현재 MSVC에서나 완전히 지원하고 나머지는 불완전하기 때문에 Windows 플랫폼에서만 온전히 사용할 수 있는 상태다. 이는 시간이 해답으로 보여진다.

1.1. main 소스 파일

[헤더 가져오기]
[객체 정의]
main 함수 정의

일반적으로 C++의 프로그램은 항상 진입점 함수 main을 요구한다. 표준에 따르면 작성하는 방식은 두가지가 있다.
#!syntax cpp
// 매개 변수가 없는 main 함수
int main()
{
    return 0;
}

// 매개 변수가 있는 main 함수
int main(int argc, const char** argv);
int main(const int& argc, const char* const* const& argv);

// 가능은 하지만 int main()을 쓰는 것이 권장된다
int main(int, const char**);
int main([[maybe_unused]] int argc, [[maybe_unused]] const char** argv);

main 함수는 C++ 프로그램의 시작과 끝을 담당한다. 프로그램의 시작 시에는 main 함수에 프로그램의 실행 매개변수를 전달할 수 있다[1]. 그리고 프로그램의 종료 시에는 정수 종료 코드를 반환한다. 종료 코드는 운영체제마다 다른 번호를 갖고 있는데, 보통 0을 반환하면 main 함수의 실행에 오류가 없이 성공적으로 종료되었다는 뜻이다. 그러나 return 0;은 반드시 해주지 않아도 된다. 아예 반환 구문이 없어도 컴파일러가 알아서 처리해준다.

2. include

C++은 C언어가 그랬듯 하나의 프로그램이 다수의 소스 코드가 모여 이루는 구조다. 원래 C++은 C언어의 구조를 그대로 가져왔기에 코드 구조도 같다. 반드시 정해진 건 아니지만, 보통 소스 코드를 선언(Declaration)구현(Implementation) 두가지로 나누어 작성하곤 한다. 선언부를 C언어에서는 헤더(Header, 머릿단)라고 불렀다. 으레 헤더는 프로그램에서 사용할 객체의 식별자 선언만 넣고, 구현 파일에서는 객체의 실제 정의를 넣는 방식이 널리 쓰인다. 그리고 헤더는 다른 헤더를 참조함으로써 그 헤더에 선언된 객체를 가져오고, 사용할 수 있다. 이렇게 하면 깔끔하고 컴파일 속도도 빠른 구조를 만들 수 있다. 만약 선언과 구현이 같은 파일에 정의되어 있다면 그걸 인라인(Inline)이다, 인라이닝 되어있다 등으로 칭한다.

여기서 가져온다는(Import) 행위는 헤더 파일을 삽입(include)함으로써 이루어진다. 삽입은 목표한 헤더 파일의 내용을 그대로 내 헤더로 복사하는 것이다. 정말 똑같이 복사하기 때문에 헤더파일과 헤더를 삽입하는 파일은 같은 파일로 합쳐지는 모양새가 된다.

2.1. 전통적인 구조

아직 많은 소스 코드는 이 규격을 따른다. 요약하자면 구현하는 데에 필요한 헤더를 먼저 가져오고, 그 아래에 클래스나 함수의 정의를 넣는 규칙이다.

2.1.1. 작성 방법

2.1.1.1. 선언부
MyPrinter.hpp
#!syntax cpp
#include <string>
#include <string_view>

class MyPrinter
{
public:
    static std::string_view ClassName;

    MyPrinter(std::string_view contents) noexcept;

    void Print() const;

    [[nodiscard]] constexpr std::string& GetText() noexcept
    {
        return myText;
    }

    [[nodiscard]] constexpr const std::string& GetText() const noexcept;

private:
    std::string myText;
};

void Print(const MyPrinter& printer);

// 메서드는 인라이닝해도 문제 없음
constexpr const std::string& MyPrinter::GetText() const noexcept
{
    return myText;
}

[헤더 삽입]
객체 정의;
선언부에서는 객체를 선언한다. 필요한 헤더를 include하고 private, public, protected 접근 권한에 따라서 클래스 내부에서 사용할 변수와 함수의 원형을 선언하면 된다. 편리하게도 메서드는 정적인 외부 연결 취급이므로 static을 붙이지 않아도 구현 내용을 담을 수 있다. 정의 주의할 점은 이 헤더 파일에서 using namespace를 사용했다가는, 헤더를 include하는 모든 파일에도 같은 이름공간이 적용되어 예기치 않은 동작이 발생할 수 있으므로 주의해야 한다. 헤더 파일에서는 이름공간을 생략하지 않는 것이 좋다.

확장자의 경우 *.h 혹은 *.hpp가 일반적이다.
2.1.1.2. 구현부
MyPrinter.cpp
#!syntax cpp
#include "MyPrinter.hpp"
#include <iostream>
// <string>와 <string_view>도 삽입 되어있음

MyPrinter::MyPrinter(std::string_view contents) noexcept
    : myText(contents.cbegin(), contents.cend())
// C++20이상에서는 myText(contents)로 바로 문자열 생성이 가능함
{}

void MyPrinter::Print() const
{
    std::cout << myText << '\n';
}

void Print(const MyPrinter& printer)
{
    printer.Print();
}

using namespace std::literals;
std::string_view MyPrinter::ClassName = "MyPrinter"sv;

[원본 클래스 헤더 삽입]
[헤더 삽입]
객체 구현;
구현부에서는 필요한 외부 헤더를 include한 다음에 헤더에서 선언한 객체의 내용을 실제로 구현한다.

확장자의 경우 비주얼 스튜디오에서는 C언어 및 C++ 소스코드에 *.cpp 확장자를 기본으로, G++에서는 *.cc 확장자를 기본으로 사용한다. 애초에 확장자는 프로그래머가 구별하기 쉽게 하기 위함이지 딱히 표준은 없다.
2.1.1.3. 실행부
Main.cpp
#!syntax cpp
#include "MyPrinter.hpp"

int main()
{
    MyPrinter hangeul_printer{ "나무위키" };
    MyPrinter english_printer{ "NamuWiki" };

    hangeul_printer.Print();
    english_printer.Print();
}

실행부에는 필요한 헤더를 include하고 main 함수를 작성한다. main 함수의 소스에 클래스를 연동시키려면 클래스 선언 헤더를 include해주면 된다. 참고로 IDE를 쓰고 있다면 해당은 없지만, 별도의 컴파일러를 쓴다면 구현 파일 또한 컴파일러에게 잘 전달해 주어야 링크 오류가 발생하지 않는다.

2.2. 이름공간을 사용한 구조

#!syntax cpp
#include <string>
#include <string_view>

class MyPrinter
{
public:
    MyPrinter(std::string_view contents) noexcept;

    void Print() const;

protected:
    std::string text;
}

namespace namuwiki::guideline
{
    class HanguelPrinter : public MyPrinter
    {
    private:
        std::string text = "안내서";
    };

    class EnglishPrinter : public MyPrinter
    {
    private:
        std::string text = "Guideline";
    };
}

namespace namuwiki
{
    class HanguelPrinter : public MyPrinter
    {
    private:
        std::string text = "나무위키";
    };

    class EnglishPrinter : public MyPrinter
    {
    private:
        std::string text = "NamuWiki";
    };
}

C++의 이름공간을 활용하면 식별자의 중복 문제를 간단히 해결할 수 있다. 이름공간 안의 객체는 외부 연결로 취급되므로 선언만 존재해도 다른 코드에서 사용할 수 있다.

2.3. Inlining 정의

#!syntax cpp
#include <string>

namespace IsThisOk
{
    class MyPrinter
    {
    public:
        void Print() const;
        void Print(const std::string& prefix) const;

    private:
        std::string text;
    };

    // 중복 정의된 함수 
    void Print(const MyPrinter& printer)
    {
        printer.Print(); // 식별자 찾지 못함
    }

    // 중복 정의된 함수 (오버로딩 때문이 아님!!!!)
    void Print(const MyPrinter& printer, const std::string& prefix)
    {
        printer.Print(prefix); // 식별자 찾지 못함
    }
}

여기서 IsThisOk::MyPrinter 클래스의 Print 메서드를 참조하는 Print 함수를 구현한다고 해보자. 그러면 두가지 방법이 있다. 다른 소스 파일에 구현하거나, 같은 헤더 파일에 구현하는 방법이 있다. 다른 소스 파일로 분리하면 아무 문제가 없다. 그런데 파일 접근 권한이 없어서 파일 생성을 못하고 수정 뿐이 못한다면? 아니면 소스를 외부에 배포하고 싶은데 간편한 사용을 위해 같은 헤더 파일에 구현하려고 한다고 해보자. 헌데 이렇게 하면 필시 링크 오류가 발생한다.

더욱이 가관인 건 오류가 두 곳에서 발생한다는 점이다. 예시를 한번 보자. 첫번째는 Print 함수가 중복 정의되었다는 오류다. 심지어 이 오류는 헤더 파일을 하나의 소스 파일에서만 써도 발생한다. 정확히 말하자면 헤더의 삽입은 파일을 병합하는 것이므로 같은 스코프에 정의된 객체는 그 내용이 인라인이면 식별자의 중복 문제가 발생한다. 두번째는 IsThisOk::MyPrinter::Print 식별자가 정의되지 않았다는 오류다. 이 오류는 식별자의 선언과 구현이 엇갈려서 생기는 문제다. Print는 오버로딩까지 된 함수라서 분명히 보이지 않는 extern "C++"도 붙어있을 것이다. 그러나 여전히 링크 오류가 발생한다. 어째서일까?
헤더를 삽입하는 건 헤더의 내용을 그대로 복사하는 것임을 항상 유의해야 한다. 그렇기 때문에 헤더를 여러 곳에서 사용하면 소스 연결(Linking) 작업이 실패한다. 한 스코프에서 식별자는 항상 유일해야 하기 때문이다. 자세히는 헤더에 정의된 객체가 여러 obj 파일에서 참조되면 그걸 하나의 식별자로 보는 게 아니라 식별자의 이름이 중복으로 정의되었다고 인식하기 때문이다. 객체의 선언만 있으면 링크 오류가 발생하지 않는다. C++에선 조금 개선되어서 함수, 함수의 매개 변수, 클래스(구조체)의 이름을 서로 구분할 수 있다. 그나마 함수는 오버로딩이 가능하지만, 여전히 전역 변수와 클래스는 인라이닝을 할 수 없다. 템플릿으로만 간접적으로 중복된 이름을 만들 수 있다.

2.3.1. 처리기 & static

#!syntax cpp
#ifndef MYCLASS_H
#define MYCLASS_H

namespace IsThisOk
{
    class MyPrinter
    {
    public:
        void Print() const
        {
            ...;
        }

    private:
        std::string text;
    };

    // 아직도 오류 발생: 중복 정의된 함수
    /*static */ void Print(const MyPrinter& printer)
    {
        printer.Print();
    }
}
#endif

C언어에서는 이 문제를 해결하기 위해 전처리기 키워드와 #ifdef, #endif를 조합하는 방법이 이용되어 왔다. 클래스는 문제가 없지만, 그러나 여전히 함수는 문제가 된다. 전처리기 키워드를 쓰더라도, 함수는 여전히 참조하는 클래스의 메서드, 다른 함수들의 구현부를 필요로 한다. 이를 해결하는 방법은 이름공간의 사용 및 전역 함수에 static을 써서 정적인 내부 연결(Static Internal Linkage)로 만드는 방법이 있다. 이러면 함수와 함수에서 참조하는 객체가 유일한 식별자로 정해지고 중복 정의 문제가 해결된다.

2.3.2. 익명 이름공간

#!syntax cpp
namespace IsThisOk
{
    namespace
    {
        class MyPrinter
        {
        public:
            // 경고: 내부 연결이지만 구현부가 없음
            void Print() const;

        private:
            std::string text;
        };

        void Print(const MyPrinter& printer)
        {
            // 정의가 없어도 사용하는 데 문제없음
            printer.Print();
        }
    }
}

namespace
{
    int aaaaa;
    int bbbbb = 0;
}

int aaaaa; // 오류!

익명 이름공간의 멤버는 모두 내부 연결로 처리된다. 즉 익명 이름공간 안의 변수는 static은 아닌데도 유일한 존재가 되며 구현부가 없는 함수도 멀쩡하게 사용할 수 있다.

3. import C++20

종래의 헤더 삽입(Include)은 사실 소스 코드를 가져온다(Import)는 개념을 포함하긴 하는데 여러가지 부작용이 있었다. 대상 헤더의 원본을 그대로 내 헤더로 복사하는 것은 간편한 구현이지만 이후 프로그래밍의 발전사와는 동떨어진 개념이 되어버렸다. 과한 식별자 추가 및 의존성 문제, 보안 문제, 문자열로 관리되는 특성 상 유연한 헤더 변경과 삽입이 어려웠다. C++20은 여기에 모듈(Module)이라는 새로운 소스 파일 규격을 도입했다.

4. 메모리 할당

4.1. 정적 할당

#!syntax cpp
#include <cstdint>
#include <utility>
#include <string_view>

// 전역 변수
float my_float;
/*static*/ int my_st_integer;

// static이어도 문제 없다.
/*static*/ std::int64_t my_i64;
/*static*/ std::int64_t* my_ptr_i64 = &my_i64;

constexpr std::int32_t my_ct_i32 = 40;
constexpr const std::int32_t* my_ct_ptr_i32 = &my_ct_i32;

class MyClass
{
public:
    static constexpr std::string_view Name = "MyClass";
    static std::string_view NotCtName1;
    static inline std::string_view NotCtName2 = "MyClass";
    constinit static inline std::string_view NotCtName3 = "MyClass";
};

int main()
{
    /*constexpr*/ MyClass instance1, instance2;

    // constexpr 객체가 아니라도 constexpr 정적 필드는 컴파일 시간에 값을 검증할 수 있다.
    static_assert(std::addressof(instance1.NotCtName1) == std::addressof(instance2.NotCtName1));
    // 사실 정적 필드는 constexpr가 아니더라도 컴파일 시간에 주소를 검증할 수 있다.
    static_assert(std::addressof(instance1.NotCtName2) == std::addressof(instance2.NotCtName2));
    static_assert(std::addressof(instance1.NotCtName3) == std::addressof(instance2.NotCtName3));
    static_assert(sizeof(MyClass) == 1);

    // 그런데 소멸자는 호출 가능하다.
    // 이 경우 소멸자에 정의된 동작이 실행되며 실제로 객체의 소멸이 이루어지는 시점은 함수의 범위가 끝나는 시점이다.
    instance1.~MyClass();
}

프로그램이 시작되면서 고정된 메모리 공간에 할당된다. 코드 내의 정적인 크기를 가진 모든 필드가 해당된다. 가령 전역 변수와 함수 심볼 테이블, 클래스의 정적 필드 등이 있다. 프로그램이 종료될 때 까지 여기의 메모리는 해제할 수 없다. 이때 클래스의 정적 필드는 클래스의 바이트 크기에 영향을 끼치지 못한다. 다만 빈 클래스는 아니므로 바이트 크기는 1이 된다. 그리고 정적 포인터가 가리키는 메모리는 여전히 할당 및 해제가 가능한데, 이는 포인터가 정적 필드인 것이지, 포인터의 대상은 정적이든 아니든 상관없기 때문이다.

4.2. 자동 할당

#!syntax cpp
#include <utility>
#include <iostream>

int main()
{
    int x = 0;

    // 0을 출력
    std::cout << x << '\n';
    // x의 주소를 출력
    std::cout << std::addressof(x) << '\n';

    return 0;
}

전역 범위가 아닌 함수 안에서 객체(Object)를 생성하면 자동 메모리 할당이다. 여기서 객체는 C++의 메모리 모델에서 보았듯이 lvalue(참조)가 아닌 필드를 의미한다. 참고할 점은 함수의 반환값은 굳이 lvalue를 명시하지 않으면 NVRO가 적용돼서 prvalue 또는 xvalue가 되어 메모리 할당이 실제로 일어나지 않는다. 그 함수의 반환값이 실제로 사용될 때만 메모리 할당이 일어나는 것이다.
예제에서는 "int x = 0"가 정수를 메모리에 할당하는것이다. 컴파일러의 구현마다 다르지만 보통 자동 할당은 운영체제 메모리의 스택에 저장된다. 이렇게 만들어진 객체는 함수의 범위(Scope)가 끝나면 회수된다. 참고로 std::array<std::array<T, S>> 같은 다차원 array는 C++17 이후에서만 스스로 해제가 가능하므로 사용한 자료형에 따라 별도로 해제 해줘야 누수가 생기지 않는다. 다음 같은 코드는 C++에선 사용할 수 없다.
#!syntax cpp
#include <utility>

int* DanglingMemory()
{
    int x = 0;
    int *ptr = std::addressof(x); // 참조 대상 소실 (Dangling Pointer/Reference)

    return ptr; // 접근 위반 오류 또는 경고. 보안 문제가 발생할 수 있다.
}

이 코드의 의도는 주소 '0'을 가리키는 포인터를 반환시키는 함수를 만드는것이다. 일단 자동 할당으로 정수 x를 생성하고, 정수 포인터 ptr에게 x의 메모리 주소를 가리키라고 명령하고 ptr를 반환하는 것. 하지만 위에서 서술했듯이, x는 자동 할당으로 만들어진 자료기 때문에 함수의 범위를 벗어나면 x에게 주어진 메모리는 운영체제가 회수한다. 요약하자면 실제로 이 함수를 사용하면 나오는건 0을 가리키는 포인터가 아니라 아무 말도 안 되는 걸 가리키는, 사용하면 안되는 포인터다. 그렇다면 이걸 어떻게 고쳐야 할까? 답은 동적 메모리 할당 (Dynamic Memory Allocation)이다.

4.3. 동적 할당

이 역시 컴파일러의 구현마다 다르지만 보통 동적 할당은 스택대신 힙(Heap) 영역에 이루어진다. C++에서는 new 자료형; 형식의 표현식을 사용하면 된다. 조금 소개하자면 T *ptr = new T;와 같이 쓸 수 있다. 위의 예제에서 의도하려 했던 함수를 만들려면 다음과 같이 코드를 짜면 된다.
#!syntax cpp
int* CreateMemory()
{
    int *ptr = new int; // 동적 할당
    *ptr = 0;

    return ptr;
}

단 여기서 주의해야 할것이 있는데, 힙의 메모리를 자료에 할당해주면 이 자료는 써먹은 다음에 반드시 풀어주어야 한다. 그렇지 않으면 메모리 누수가 일어 나게 된다. new로 동적 할당한 메모리는 delete 연산자로 풀어주자.
#!syntax cpp
#include <iostream>

//... CreateMemory() 함수 정의

int main()
{
    int *ptr = CreateMemory();

    // "0" 출력
    std::cout << *ptr << '\n'; 

    delete ptr; // ptr이 가리키는 메모리를 해제한다. 이제 ptr은 사용할 수 없다.
}

헌데 동적 메모리 할당은 자동/정적 할당보다 주의가 요구 된다. 특히 방대한 자료를 다루는 프로그램이라면 필요없는 메모리를 해제하지 않으면 안된다. 메모리 초과로 프로그램이 먹통이 되버리는게 대다수다. 반대로 이미 해제한 메모리를 재차 해제하려 하거나(Double-Free), 이미 해제한 메모리에 접근하려 할 경우(Use-After-Free)에도 치명적인 문제가 발생한다. 마지막으로 new로 이미 할당 된 메모리(스택도 포함)에 또 할당 할 수도 있다. 해당 방법으로 스택 메모리에다가 할당한 경우, 따로 해제를 해 주지 않아도 된다. 다만 클래스라면 소멸자는 호출 해 주어야 한다. 그런데 만약 원래 동적 할당했던 필드에 할당했으면 이전에 할당했었던 메모리는 주소만 덮어 씌워지는 것이라 자동으로 해제되지 않는다! 때문에 알아서 이전 메모리를 해제해줘야만 한다. 그러나 이 작업이 여간 힘든 것이 아니므로 중복 할당은 피해야 한다.

만약 백엔드에서 서로 다른 런타임을 사용할 때는 경우 해제(delete)는 할당(new)한 프로그램이 사용하는 런타임 영역 내에서 이루어 져야 한다. GCC가 사용하는 libstdc++, LLVM이 사용하는 libc++, 그리고 MSVC가 사용하는 msvcp는 서로 다른 ABI를 가지고 있으며 따라서 할당 방법도 다르다. libstdc++에서 할당한 포인터를 msvcp가 해제하는 경우 힙 커럽션등의 문제가 생길 수 있다.

특히 C언어/문법에서 소개하는 malloc 등으로 C++의 클래스를 건드리면 안된다. 왜냐하면 C++의 객체와 C의 메모리 공간은 엄연히 다른 존재이기 때문이다. 메모리 공간은 C++에서도 존재하며 똑같이 포인터로 표현된다. 반면 객체는 특정 메모리 공간을 특수한 용도로 사용하겠다고 지정한 상태를 말한다. 다시 말해서 바로 해당 메모리 공간에는 어떤 클래스가 들어있고, 클래스 인스턴스의 수명(Lifetime)이 관리되며, 곧 생성자와 소멸자를 호출할거라고 선포한 상태란 것이다.

결론적으로 C++의 객체를 C언어 방식으로 할당하면 생성자, 소멸자가 호출되지 않으므로, 정말 특수한 상황이 아니면 malloc, free 등은 쓰지 말아야 한다. new가 런타임 오버헤드 때문에 느리기는 하지만 객체 수명 관리가 안되므로 프로그램 동작이 꼬일 위험성이 높다.
#!syntax cpp
#include <new>
#include <memory>
#include <span>

struct MyData
{
    void ExecuteData() const {}

    int a;
    int b;
    int c;
};

struct MyData_Copy
{
    void ExecuteData() const {}

    int a;
    int b;
    int c;
};

struct Packet
{
    [[nodiscard]]
    std::span<const char> Serialize() const noexcept
    {
        return std::span{ reinterpret_cast<const char*>(myData), mySize };
    }

    void* myData;
    size_t mySize;
};

int main()
{
    MyData data{};

    Packet packet1
    {
        .myData = new (std::addressof(data)) MyData{},
        .mySize = sizeof(MyData)
    };

    // acq_data1_packet1는 data를 가리키는 포인터이다.
    // MyData* acq_data1_packet1
    auto acq_data1_packet1 = std::launder(reinterpret_cast<MyData*>(packet1.myData));
    acq_data1_packet1->ExecuteData();

    Packet packet2
    {
        .myData = new MyData_Copy{}, // MyData과 똑같은 구성
        .mySize = sizeof(MyData_Copy)
    };

    // packet2.myData에는 MyData가 아닌 MyData_Copy가 들어있으므로 std::launder는 정의되지 않은 동작에 돌입한다.
    // 따라서 acq_data1_packet2의 값은 알 수 없다.
    auto acq_data1_packet2 = std::launder(reinterpret_cast<MyData*>(packet2.myData));

    Packet packet3
    {
        .myData = new MyData{},
        .mySize = sizeof(MyData)
    };
    delete packet3.myData;

    // packet3.myData는 해제된 메모리 이므로 std::launder는 정의되지 않은 동작에 돌입한다.
    // 따라서 acq_data1_packet3의 값은 알 수 없다.
    auto acq_data1_packet3 = std::launder(reinterpret_cast<MyData*>(packet3.myData));
}

이를 어떻게든 C++안에서 해결하고자 한다면 표준 라이브러리에서 std::launderC++17 , std::start_lifetime_asC++23 , std::start_lifetime_as_arrayC++23 라는 메모리 공간을 객체로 취급하는 함수를 제공한다. 여기서 std::launder는 실제로는 메모리 공간에 객체가 이미 있어야 하므로 조금 더 안정적인 reinterpret_cast를 함수의 모양으로 제공하는 꼴이다.

4.4. 스마트 포인터 (Smart Pointer)

이토록 많은 메모리 문제를 방지하기 위해 Modern C++에서는 raw 포인터의 사용은 자제하도록 권고된다. C++ 문서에서 귀에 못이 박히도록 설명하고 있지만, 현세대 C++에서 가장 큰 문제점을 안고 있는 부분이므로 대신 표준 라이브러리의 포인터 래퍼 또는 스마트 포인터를 사용하길 바란다. 굳이 써야 한다면 성능이 정말 중요한 부분 또는 인터페이스에는 보이지 않는 cpp 소스 구현 부분에만 포인터를 사용하는 것이 좋다.

4.5. 참조 횟수 세기 포인터 (std::shared_ptr)

#!syntax cpp
#include <memory>

long acquire_value_1(std::shared_ptr<long> ptr)
{
    // ptr의 참조 횟수 증가
    return *ptr;
}

long acquire_value_2(const std::shared_ptr<long>& ptr)
{
    // ptr의 참조 횟수 유지
    return *ptr;
}

long acquire_value_3(std::shared_ptr<long>&& ptr)
{
    // ptr은 이 함수가 끝나자 마자 바로 소멸
    return *ptr;
}

int main()
{
    // (1) std::shared_ptr<long>
    auto ptr1 = std::make_shared<long>(472012831L);
    // ptr1의 참조 횟수: 1

    // (1-1a) std::weak_ptr<long>
    std::weak_ptr ptr1_weaker = ptr1;
    // ptr1의 참조 횟수: 1

    // (1-1b) std::shared_ptr<long>
    std::shared_ptr ptr1_derive = ptr1;
    // ptr1의 참조 횟수: 2

    // (1-2) std::shared_ptr<long>
    auto ptr1_again = ptr1_weaker.lock();
    // ptr1의 참조 횟수: 3

    long val1 = acquire_value_1(ptr1);
    // ptr1의 참조 횟수: 4

    long val2 = acquire_value_2(ptr1);
    // ptr1의 참조 횟수: 4

    long val3 = acquire_value_2(std::move(ptr1));
    // ptr1의 참조 횟수: 4

    // 4000 반환
    long val4 = acquire_value_3(std::make_shared<long>(4000));

    // (2) std::shared_ptr<int[]>
    std::shared_ptr<int[]> ptr(new int[128]);
}

표준 라이브러리의 스마트 포인터는 std::shared_ptr, std::unique_ptr 두 종류가 있다. std::shared_ptr은 참조 카운터를 사용하기 때문에 해당 포인터를 잡고 있는 모든 범위에서 벗어나면 스스로 해제된다. 다음으로 std::unique_ptr는 단일 소유권만 인정하는 스마트 포인터다. std::unique_ptr를 소유하지 않은 인스턴스에서는 메모리 해제가 불가능하다. 즉 오직 소멸자에서만 메모리가 해제할 수 있다. 또한 std::unique_ptr 자체적으로는 복사가 불가능하고 이동 연산만 가능하다는 특징이 있다.
여담으로 std::shared_ptr는 몇가지 문제가 있는데, 첫번째로 성능이 느린 편이고, 두번째로는 순환 참조 문제가 발생할 가능성이 있다. 소유권이 없는 std::weak_ptrstd::shared_ptr를 그나마 안정적으로 사용할 수 있다. 대부분의 경우 할당한 메모리를 참조만 충분하므로 std::unique_ptr를 응용하면 좋다.

4.6. 배타적 소유 포인터 (std::unique_ptr)



[1] 예를 들어서 Windows에서는 프로그램의 바로가기를 만들고 프로그램 경로 뒤에 -path "C:\\Program Files\\Microsoft"처럼 매개변수를 전달할 수 있다.



파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는
문서의 r331
, 9번 문단
에서 가져왔습니다. 이전 역사 보러 가기
파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는 다른 문서에서 가져왔습니다.
[ 펼치기 · 접기 ]
문서의 r331 (이전 역사)
문서의 r331 (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)

문서의 r (이전 역사)