C++ 전방 선언: 헤더 의존성 줄이기, 불완전 타입의 제약, Pimpl 소멸자 위치

이 글의 핵심

헤더끼리 서로 include하면 컴파일 에러가 나거나, 헤더 하나를 고칠 때마다 프로젝트 대부분이 다시 빌드되는 문제가 생깁니다. 전방 선언은 이 의존성을 끊는 가장 가벼운 도구지만, 크기나 멤버를 알아야 하는 곳에서는 쓸 수 없고 스마트 포인터와 함께 쓸 때 불완전 타입 에러가 나기 쉽습니다. 언제 헤더를 포함하고 언제 전방 선언으로 충분한지 기준과 Pimpl 적용 예제를 정리했습니다.

전방 선언이란?

컴파일 시간과 헤더 의존성을 줄이려면 전체 정의 대신 이름만 알려 주는 전방 선언을 적절히 섞는 것이 중요합니다. 이 글에서는 어떤 경우에 포인터·참조만으로 충분한지, 어디서 반드시 전체 정의가 필요한지 구분하는 기준을 익힐 수 있습니다.

전방 선언은 클래스나 함수를 정의하기 전에 “이런 이름이 존재한다”는 사실만 컴파일러에 알려 주는 선언입니다.

// 전방 선언
class B;

class A {
    B* ptr;  // OK: 포인터만 사용
};

class B {
    int value;
};

class B; 이후 B의 정의가 나올 때까지 B는 불완전 타입(incomplete type) 상태입니다. 컴파일러는 B라는 클래스가 있다는 것만 알 뿐, 크기도 멤버도 모릅니다. 그런데도 A 안에 B* ptr을 둘 수 있는 이유는 A의 레이아웃을 잡는 데 필요한 것이 “포인터 하나의 크기”뿐이기 때문입니다. 이 한 줄이 “헤더를 포함하지 않고도 타입 이름을 쓸 수 있다”는 전방 선언의 핵심을 전부 보여 줍니다.

왜 필요한가?

// ❌ 헤더 포함 (느림, 의존성 증가)
#include "b.h"

class A {
    B* ptr;
};

// ✅ 전방 선언 (빠름, 의존성 감소)
class B;

class A {
    B* ptr;
};

“느림”과 “빠름”의 차이는 한 파일만 보면 잘 드러나지 않습니다. 문제는 전이(transitive)입니다. a.h가 b.h를 포함하면, a.h를 포함하는 모든 .cpp는 b.h와 b.h가 포함하는 모든 헤더까지 파싱하게 됩니다. 그리고 b.h의 private 멤버 하나만 바꿔도 빌드 시스템은 타임스탬프를 보고 a.h를 쓰는 모든 번역 단위를 다시 컴파일합니다. 전방 선언으로 바꾸면 a.h를 쓰는 파일들은 b.h의 변경에서 완전히 분리되고, 실제로 B의 멤버를 쓰는 a.cpp만 다시 빌드됩니다.

트레이드오프도 있습니다. 전방 선언은 이름을 중복해서 적는 것이라, B가 나중에 namespace core 안으로 이동하거나 using B = BImpl<int>; 같은 별칭으로 바뀌면 흩어져 있는 class B;가 전부 틀린 선언이 됩니다. 그래서 규모가 큰 라이브러리는 <iosfwd>처럼 전방 선언만 모아 둔 *_fwd.h 헤더를 따로 제공하고, 사용자는 그 헤더를 포함하게 합니다. Google C++ Style Guide가 “다른 프로젝트의 타입은 전방 선언하지 말고 헤더를 포함하라”고 권하는 것도 이런 유지보수 비용 때문입니다. 반대로 LLVM 같은 프로젝트는 빌드 시간 때문에 적극적으로 전방 선언을 씁니다. 정답이 하나가 아니라 내가 소유한 타입인가가 판단 기준이 됩니다.

사용 가능한 경우

class B;

class A {
    // ✅ 포인터
    B* ptr;
    
    // ✅ 참조
    B& ref;
    
    // ✅ 함수 매개변수
    void func(B* b);
    void func2(const B& b);
    
    // ✅ 함수 반환 타입
    B* getB();
    
    // ❌ 멤버 변수 (크기 필요)
    // B member;
    
    // ❌ 상속 (정의 필요)
    // class A : public B {};
    
    // ❌ 멤버 함수 호출
    // void test() { ptr->method(); }
};

규칙을 한 문장으로 줄이면 “B의 크기나 멤버를 알아야 하는 순간 정의가 필요하다”입니다. 함수 선언의 매개변수와 반환 타입은 값 타입이어도 됩니다. B makeB();나 void take(B b);처럼 선언만 하는 것은 불완전 타입으로도 가능하고, 그 함수를 정의하거나 호출하는 곳에서만 B의 정의가 필요합니다. 반대로 sizeof(B), new B, delete ptr, ptr->method(), std::vector<B>의 원소 접근처럼 레이아웃이나 멤버가 필요한 표현식은 모두 정의를 요구합니다.

특히 delete ptr은 주의해야 합니다. 불완전 타입 포인터를 delete하면 컴파일 에러가 아니라 경고(deleting pointer to incomplete type 'B' may cause undefined behavior)만 나오고 컴파일이 됩니다. 이때 B가 사소하지 않은 소멸자를 가지고 있으면 소멸자가 호출되지 않는 미정의 동작이 됩니다. 경고를 무시하고 넘어가면 리소스 누수로 이어지므로, -Werror=delete-incomplete(Clang) 같은 옵션으로 에러로 격상해 두는 편이 안전합니다.

실전 예시

예시 1: 순환 의존성 해결

// a.h
#ifndef A_H
#define A_H

class B;  // 전방 선언

class A {
private:
    B* b;
    
public:
    A();
    ~A();
    void setB(B* b);
    void useB();
};

#endif

// b.h
#ifndef B_H
#define B_H

class A;  // 전방 선언

class B {
private:
    A* a;
    
public:
    B();
    ~B();
    void setA(A* a);
    void useA();
};

#endif

// a.cpp
#include "a.h"
#include "b.h"  // 구현에서 포함

A::A() : b(nullptr) {}
A::~A() {}

void A::setB(B* b) {
    this->b = b;
}

void A::useB() {
    if (b) {
        // B의 메서드 사용 가능
    }
}

두 헤더가 서로를 #include하면 헤더 가드 때문에 무한 포함은 막히지만, 대신 한쪽 헤더가 빈 내용으로 처리됩니다. a.h가 먼저 열리면 A_H가 정의된 상태로 b.h에 들어가고, b.h 안의 #include "a.h"는 가드에 걸려 아무것도 가져오지 못합니다. 그 결과 b.h에서 A를 쓰는 줄에서 error: 'A' does not name a type(GCC) 또는 unknown type name 'A'(Clang) 같은 에러가 납니다. 어떤 파일이 먼저 포함되느냐에 따라 에러가 났다 안 났다 하기 때문에 원인을 찾기가 까다롭습니다.

위 예제는 양쪽 모두 상대를 포인터로만 들고 있으므로 헤더에서는 전방 선언으로 충분하고, 실제 멤버를 쓰는 a.cpp와 b.cpp에서만 두 헤더를 모두 포함합니다. 제 경험상 순환 include를 처음 만났을 때 흔히 하는 실수는 에러가 난 쪽에만 전방 선언을 넣고, 여전히 인라인 함수 본문에서 상대 타입의 멤버를 호출하는 것입니다. 그러면 에러 메시지만 invalid use of incomplete type으로 바뀔 뿐 문제는 그대로입니다. 멤버를 호출하는 코드를 .cpp로 옮기는 것까지가 한 세트입니다.

예시 2: 컴파일 시간 단축

// engine.h
#ifndef ENGINE_H
#define ENGINE_H

// 전방 선언 (헤더 포함 불필요)
class Renderer;
class Physics;
class Audio;

class Engine {
private:
    Renderer* renderer;
    Physics* physics;
    Audio* audio;
    
public:
    Engine();
    ~Engine();
    
    void init();
    void update();
    void render();
};

#endif

// engine.cpp
#include "engine.h"
#include "renderer.h"  // 구현에서만 포함
#include "physics.h"
#include "audio.h"

Engine::Engine() 
    : renderer(nullptr)
    , physics(nullptr)
    , audio(nullptr) {}

Engine::~Engine() {
    delete renderer;
    delete physics;
    delete audio;
}

void Engine::init() {
    renderer = new Renderer();
    physics = new Physics();
    audio = new Audio();
}

engine.h를 포함하는 파일은 게임 루프, 에디터, 테스트 등 수십 곳일 수 있지만, renderer.h처럼 그래픽 API 헤더까지 끌고 오는 무거운 헤더는 engine.cpp 한 곳에서만 열립니다. 렌더러의 내부 구현을 고쳐도 다시 컴파일되는 것은 engine.cpp와 렌더러 자신의 파일뿐입니다.

다만 이 예제는 설명을 위해 원시 포인터와 new/delete를 썼기 때문에 복사 문제가 숨어 있습니다. Engine을 복사하면 컴파일러가 만든 복사 생성자가 포인터 값만 복사하고, 두 객체가 소멸할 때 같은 Renderer를 두 번 delete합니다. 실제 코드라면 Engine(const Engine&) = delete;로 복사를 막거나, 아래 모범 사례처럼 std::unique_ptr을 쓰는 편이 맞습니다. unique_ptr도 불완전 타입과 함께 쓸 수 있지만, 소멸자 위치에 대한 규칙(문제 3 참고)을 지켜야 합니다.

예시 3: Pimpl 패턴

// widget.h
#ifndef WIDGET_H
#define WIDGET_H

#include <memory>

class Widget {
public:
    Widget();
    ~Widget();
    
    void doSomething();
    
private:
    class Impl;  // 전방 선언
    std::unique_ptr<Impl> pImpl;
};

#endif

// widget.cpp
#include "widget.h"
#include <iostream>
#include <vector>

// 구현 클래스 정의
class Widget::Impl {
public:
    std::vector<int> data;
    
    void doSomething() {
        std::cout << "Doing something" << std::endl;
    }
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}

Widget::~Widget() = default;

void Widget::doSomething() {
    pImpl->doSomething();
}

Pimpl(Pointer to implementation)은 전방 선언을 극단까지 밀어붙인 형태입니다. Widget의 모든 private 데이터가 Impl 뒤로 숨기 때문에 widget.h에는 <vector>조차 필요 없고, Impl에 필드를 추가해도 sizeof(Widget)은 포인터 하나 크기로 유지됩니다. 그래서 헤더 사용자는 재컴파일할 필요가 없고, 공유 라이브러리라면 ABI 호환성도 지킬 수 있습니다.

Widget::~Widget() = default;를 헤더가 아닌 .cpp에 둔 것이 이 코드에서 가장 중요한 한 줄입니다. 이유는 아래 “문제 3”에서 설명합니다. 대가도 분명합니다. 모든 멤버 접근이 포인터를 한 번 더 거치고, 객체마다 힙 할당이 한 번 추가되며, 인라인 최적화가 어려워집니다. 초당 수백만 번 호출되는 작은 값 타입에는 맞지 않고, 헤더가 자주 바뀌는 큰 클래스나 외부에 배포하는 라이브러리 인터페이스에 적합합니다.

예시 4: 인터페이스 분리

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

#include <string>

// 전방 선언
class FileWriter;
class ConsoleWriter;

class Logger {
private:
    FileWriter* fileWriter;
    ConsoleWriter* consoleWriter;
    
public:
    Logger();
    ~Logger();
    
    void log(const std::string& message);
    void setFileOutput(const std::string& filename);
    void setConsoleOutput(bool enabled);
};

#endif

// logger.cpp
#include "logger.h"
#include "file_writer.h"
#include "console_writer.h"

Logger::Logger() 
    : fileWriter(nullptr)
    , consoleWriter(nullptr) {}

Logger::~Logger() {
    delete fileWriter;
    delete consoleWriter;
}

void Logger::log(const std::string& message) {
    if (fileWriter) {
        fileWriter->write(message);
    }
    if (consoleWriter) {
        consoleWriter->write(message);
    }
}

이 구조의 장점은 Logger를 쓰는 쪽이 파일 I/O나 콘솔 구현에 대해 전혀 알 필요가 없다는 점입니다. logger.h는 <string>만 포함하므로 <fstream>이 모든 번역 단위에 퍼지지 않습니다. 한 단계 더 나아가면 FileWriter와 ConsoleWriter를 공통 추상 클래스 Writer로 묶고 std::vector<std::unique_ptr<Writer>>를 두는 방식이 됩니다. 그러면 새 출력 대상을 추가해도 logger.h는 바뀌지 않습니다. 전방 선언이 “컴파일 의존성”을 끊는다면, 인터페이스 추상화는 “설계 의존성”까지 끊는 셈입니다.

템플릿과 전방 선언

// 템플릿 전방 선언
template<typename T>
class Container;

class MyClass {
    Container<int>* ptr;  // OK
};

// 템플릿 정의
template<typename T>
class Container {
    T data;
};

템플릿 전방 선언은 템플릿 매개변수 목록까지 정확히 맞춰야 합니다. 기본 템플릿 인자가 있는 경우가 함정인데, 기본 인자는 한 번만 지정할 수 있어서 전방 선언과 정의 양쪽에 = int를 적으면 redefinition of default argument 에러가 납니다.

표준 라이브러리 타입은 직접 전방 선언하면 안 됩니다. namespace std { class string; } 같은 코드는 표준상 미정의 동작입니다. std::string은 실제로 std::basic_string<char, ...>의 별칭이고, 구현마다 인라인 네임스페이스(libc++의 std::__1)를 쓰기 때문에 선언이 맞지 않습니다. 입출력 스트림은 표준이 제공하는 <iosfwd>를 포함하면 되고, 나머지는 해당 헤더를 포함해야 합니다.

열거형도 기반 타입을 명시하면 전방 선언할 수 있습니다. enum class Color : std::uint8_t;(범위 있는 열거형은 기반 타입 생략 시 int)처럼 선언해 두면 헤더에서 열거형을 값으로 매개변수에 쓸 수 있고, 열거자 목록은 .cpp에 둘 수 있습니다. 범위 없는 enum은 기반 타입을 적어야만 전방 선언이 가능합니다.

함수 전방 선언

// 함수 전방 선언
void process(int x);
int calculate(double a, double b);

class MyClass {
public:
    void useFunction() {
        process(10);
        int result = calculate(3.14, 2.71);
    }
};

// 함수 정의 (다른 파일에)
void process(int x) {
    // 구현
}

int calculate(double a, double b) {
    return static_cast<int>(a + b);
}

함수는 선언과 정의가 원래 분리되는 개념이라, 헤더에 함수 원형을 적는 것 자체가 전방 선언입니다. 여기서 흔히 만나는 문제는 컴파일 에러가 아니라 링크 에러입니다. 선언만 있고 정의가 어떤 오브젝트 파일에도 없으면 컴파일은 통과하고 링크 단계에서 undefined reference to 'process(int)'(GCC/Clang) 또는 LNK2019: unresolved external symbol(MSVC)이 납니다. 선언과 정의의 매개변수 타입이 조금이라도 다르면(int 대 long) C++에서는 다른 오버로드로 취급되기 때문에 같은 에러가 납니다. 정의가 있는 .cpp가 빌드 대상에 포함되어 있는지, 시그니처가 정확히 같은지 두 가지를 먼저 확인하면 대부분 해결됩니다.

자주 발생하는 문제

문제 1: 불완전한 타입 사용

class B;

class A {
    // ❌ 크기를 알 수 없음
    // B member;
    
    // ❌ 메서드 호출 불가
    void test(B* b) {
        // b->method();  // 에러
    }
    
    // ✅ 포인터/참조만
    B* ptr;
    B& ref;
};

문제 2: 헤더에서 구현

// ❌ 헤더에서 메서드 호출
class B;

class A {
    B* ptr;
    
    void test() {
        ptr->method();  // 에러: B가 불완전
    }
};

// ✅ cpp 파일에서 구현
// a.h
class B;

class A {
    B* ptr;
    void test();
};

// a.cpp
#include "a.h"
#include "b.h"

void A::test() {
    ptr->method();  // OK
}

클래스 본문 안에 정의한 멤버 함수는 암묵적으로 inline이고, 이 헤더를 포함하는 모든 번역 단위에서 컴파일됩니다. 그 시점에 B는 불완전 타입이므로 error: invalid use of incomplete type 'class B'(GCC) 또는 member access into incomplete type 'B'(Clang)가 납니다. 해결책은 본문을 .cpp로 옮기는 것입니다. 인라인 성능이 꼭 필요하다면 헤더 아래쪽에 b.h를 포함한 뒤 inline void A::test() { ... }를 두는 방법도 있지만, 그러면 전방 선언으로 얻은 의존성 절감이 사라집니다.

문제 3: 스마트 포인터와 전방 선언

// ❌ 소멸자 문제
// widget.h
class Impl;

class Widget {
    std::unique_ptr<Impl> pImpl;
};  // 소멸자에서 Impl 크기 필요

// ✅ 소멸자 선언
// widget.h
class Impl;

class Widget {
public:
    Widget();
    ~Widget();  // 선언
    
private:
    std::unique_ptr<Impl> pImpl;
};

// widget.cpp
#include "widget.h"
#include "impl.h"

Widget::~Widget() = default;  // 정의

첫 번째 코드의 주석 “소멸자에서 Impl 크기 필요”를 정확히 풀어 쓰면 이렇습니다. 소멸자를 선언하지 않으면 컴파일러가 헤더 안에서 암묵적 인라인 소멸자를 만들고, 그 소멸자는 std::unique_ptr<Impl>의 소멸자를, 다시 std::default_delete<Impl>::operator()를 인스턴스화합니다. 표준 라이브러리는 이 안에서 static_assert(sizeof(Impl) > 0) 같은 검사를 하기 때문에 Widget을 파괴하는 코드를 컴파일하는 순간 에러가 납니다. GCC에서는 invalid application of 'sizeof' to incomplete type 'Impl', MSVC에서는 C2027: use of undefined type 'Impl' 형태로, 에러 위치가 <memory> 헤더 깊숙한 곳으로 표시되어 원인을 알아보기 어렵습니다.

그래서 소멸자를 헤더에서 선언만 하고 Impl이 완전한 .cpp에서 정의해야 합니다. 같은 이유로 이동 생성자·이동 대입 연산자(= default), 그리고 unique_ptr을 재설정하는 모든 함수도 .cpp에서 정의해야 합니다. 생성자도 예외가 아닙니다. 생성자에서 예외가 나면 이미 생성된 멤버를 파괴해야 하므로 생성자 역시 unique_ptr의 소멸자를 필요로 합니다. std::shared_ptr은 삭제자를 생성 시점에 캡처하기 때문에 이 제약이 없지만, 소유권이 공유되는 의미가 되므로 Pimpl에서는 보통 unique_ptr을 씁니다.

전방 선언 vs 헤더 포함

// 전방 선언 (권장)
// - 컴파일 시간 단축
// - 의존성 감소
// - 재컴파일 최소화
class B;

class A {
    B* ptr;
};

// 헤더 포함 (필요시)
// - 멤버 변수
// - 상속
// - 메서드 호출
#include "b.h"

class A {
    B member;  // 크기 필요
};

헤더에서 값 멤버, 소유 포인터, 빌린 포인터 나누기

// myclass.h
#ifndef MYCLASS_H
#define MYCLASS_H

#include <string>  // 필요한 것만 포함
#include <memory>

// 전방 선언 활용
class Helper;
class Database;

class MyClass {
public:
    MyClass();
    ~MyClass();
    
    void process();
    
private:
    std::string name;
    std::unique_ptr<Helper> helper;
    Database* db;
};

#endif

// myclass.cpp
#include "myclass.h"
#include "helper.h"    // 구현에서 포함
#include "database.h"

MyClass::MyClass() 
    : helper(std::make_unique<Helper>())
    , db(nullptr) {}

MyClass::~MyClass() = default;

void MyClass::process() {
    helper->doWork();
    if (db) {
        db->query();
    }
}

이 예제에는 역할이 다른 두 포인터가 섞여 있습니다. helper는 MyClass가 소유하므로 unique_ptr이고, db는 외부에서 수명이 관리되는 객체를 빌려 쓰는 것이므로 원시 포인터입니다. 둘 다 불완전 타입으로 선언할 수 있지만, unique_ptr<Helper> 때문에 ~MyClass()는 반드시 .cpp에 있어야 합니다. std::string은 값 멤버라 정의가 필요하므로 <string>을 포함한 것이고요. 헤더를 정리할 때는 “이 멤버가 값인가, 소유 포인터인가, 빌린 포인터인가”를 하나씩 따져 보면 어디까지 전방 선언으로 버틸 수 있는지 자연스럽게 정해집니다. include-what-you-use(IWYU) 같은 도구를 CI에 붙이면 불필요한 #include와 전방 선언으로 대체 가능한 곳을 자동으로 찾아 줍니다.

컴파일 시간 단축: 무엇이 줄어드는가

#include는 전처리기가 해당 헤더의 모든 텍스트를 펼칩니다. 전방 선언은 의존 그래프의 간선을 끊어 다음을 줄입니다.

  • 전처리·파싱·템플릿 인스턴스화에 걸리는 총량
  • 한 헤더 수정 시 다시 컴파일해야 할 번역 단위 수

특히 무거운 서드파티 헤더(Boost, GUI, 메타프로그래밍 헤비 헤더) 앞에 불완전 타입으로 버티면 체감이 큽니다. CI에서 빌드 시간이 병목이면 헤더 의존성 그래프를 주기적으로 점검하는 것이 좋습니다.

순환 의존성: 패턴 정리

전형적인 해결 순서는 다음과 같습니다.

  1. 한쪽만 포인터/참조로 바꾸고 전방 선언.
  2. 인터페이스 추출: 순환을 끊는 작은 추상 기반 클래스(또는 함수 포인터)를 중간에 둠.
  3. PIMPL: 한쪽 구현을 .cpp로 몰아 private 멤버를 불완전 타입 뒤로 숨김.

순환을 “friend로 풀기”는 결합도를 높이므로 최후 수단으로 두는 편이 안전합니다.

포인터·참조만 가능한 이유 (불완전 타입)

컴파일러가 sizeof(T)·레이아웃·일부 표현식을 요구할 때는 완전한 타입이 필요합니다. 포인터와 참조는 대개 동일한 크기·정렬로 처리할 수 있어, 클래스 정의 없이 이름만 알아도 멤버로 둘 수 있습니다.

반면 값 멤버 T obj, 상속 class D : public B, 인라인 멤버 함수 안에서 t.method() 는 B의 정의가 필요합니다.

PIMPL 패턴 심화

  • 헤더: class Impl; + std::unique_ptr<Impl>(또는 전용 소멸자 선언).
  • cpp: Impl 정의, 모든 비인라인 멤버에서 pImpl-> 사용.
  • 이점: ABI 안정성(private 멤버 변경이 헤더에 안 드러남), 컴파일 격리.
  • 주의: 기본 삭제자를 쓰는 unique_ptr<Impl>이라면 소멸자·이동 연산을 cpp에 정의해야 합니다. 복사 의미가 필요하면 Impl을 깊은 복사하는 복사 생성자·대입 연산자를 cpp에 직접 구현합니다. shared_ptr로 바꾸면 복사는 컴파일되지만 두 Widget이 같은 Impl을 공유하게 되어 값 의미가 깨집니다.
// widget.cpp
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

아래 헤더처럼 이동 연산을 선언만 해 두고, 위처럼 cpp에서 = default로 정의합니다. 헤더에서 = default로 끝내면 인라인 정의가 되어 다시 불완전 타입 에러가 납니다. 이동된 뒤의 Widget은 p_가 nullptr이므로, 이동 후 객체의 멤버 함수를 호출하면 널 역참조가 된다는 점도 문서화해 두는 것이 좋습니다.

// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
private:
    class Impl;
    std::unique_ptr<Impl> p_;
};

FAQ

Q1: 전방 선언은 언제 사용?

A:

  • 포인터/참조만 사용
  • 순환 의존성 해결
  • 컴파일 시간 단축

Q2: 언제 헤더 포함?

A:

  • 멤버 변수
  • 상속
  • 메서드 호출

Q3: 컴파일 시간 차이는?

A: 큰 프로젝트에서 큰 차이. 재컴파일 최소화.

Q4: 스마트 포인터는?

A: unique_ptr<불완전타입> 멤버가 있으면 소멸자(와 이동 연산)를 cpp 파일에서 정의해야 합니다. shared_ptr은 이 제약이 없습니다.

Q5: 템플릿은?

A: 전방 선언 가능하지만 정의는 헤더에.

Q6: 전방 선언 학습 리소스는?

A:

  • “Effective C++”
  • “Large-Scale C++”
  • cppreference.com

같이 보면 좋은 글