Pimpl 관용구 대 순수 가상 클래스 인터페이스


118

프로그래머가 Pimpl 관용구 또는 순수 가상 클래스 및 상속을 선택하도록 만드는 것이 무엇인지 궁금합니다.

나는 pimpl 관용구가 각 공용 메소드와 객체 생성 오버 헤드에 대해 하나의 명시적인 추가 간접 지시를 제공한다는 것을 이해합니다.

반면에 순수 가상 클래스는 상속 구현을위한 암시 적 간접 (vtable)과 함께 제공되며 객체 생성 오버 헤드가 없음을 이해합니다.
편집 : 그러나 외부에서 개체를 만드는 경우 공장이 필요합니다.

순수 가상 클래스가 pimpl 관용구보다 덜 바람직한 이유는 무엇입니까?


3
좋은 질문입니다. 똑같은 질문을하고 싶었습니다. boost.org/doc/libs/1_41_0/libs/smart_ptr/sp_techniques.html
Frank

답변:


60

C ++ 클래스를 작성할 때 그것이 될지 여부를 생각하는 것이 적절합니다.

  1. 가치 유형

    가치로 복사하면 정체성은 중요하지 않습니다. std :: map의 키가되는 것이 적절합니다. 예를 들어 "문자열"클래스, "날짜"클래스 또는 "복잡한 숫자"클래스입니다. 그러한 클래스의 인스턴스를 "복사"하는 것은 의미가 있습니다.

  2. 엔티티 유형

    정체성이 중요합니다. 항상 "값"이 아닌 참조로 전달됩니다. 종종 클래스의 인스턴스를 전혀 "복사"하는 것이 이치에 맞지 않습니다. 이해가된다면 다형성 "Clone"방법이 일반적으로 더 적합합니다. 예 : 소켓 클래스, 데이터베이스 클래스, "정책"클래스, 기능적 언어에서 "클로저"가되는 모든 것.

pImpl 및 순수 추상 기본 클래스는 모두 컴파일 시간 종속성을 줄이는 기술입니다.

그러나 저는 pImpl을 사용하여 Value 유형 (유형 1)을 구현하고 때로는 커플 링 및 컴파일 시간 종속성을 최소화하려는 경우에만 사용합니다. 종종 귀찮은 가치가 없습니다. 올바르게 지적했듯이 모든 공용 메서드에 대해 전달 메서드를 작성해야하므로 구문 오버 헤드가 더 많습니다. 유형 2 클래스의 경우 항상 연관된 팩토리 메소드와 함께 순수한 추상 기본 클래스를 사용합니다.


6
이 답변에 대한 Paul de Vrieze의 의견을 참조하십시오 . 라이브러리에 있고 클라이언트를 다시 빌드하지 않고 .so / .dll을 교체하려는 경우 Pimpl과 Pure Virtual이 크게 다릅니다. 클라이언트는 이름으로 pimpl 프런트 엔드에 연결되므로 이전 메서드 서명을 유지하는 것으로 충분합니다. 순수한 추상 케이스의 OTOH는 vtable 인덱스로 효과적으로 연결하므로 메서드를 재정렬하거나 중간에 삽입하면 호환성이 깨집니다.
SnakE

1
바이너리 비교 성을 유지하기 위해 Pimpl 클래스 프런트 엔드에서만 메서드를 추가 (또는 재정렬) 할 수 있습니다. 논리적으로 말하면, 당신은 여전히 ​​인터페이스를 변경했으며 약간 이상해 보입니다. 여기서 대답은 "종속성 주입"을 통한 단위 테스트에도 도움이 될 수있는 합리적인 균형입니다. 그러나 대답은 항상 요구 사항에 따라 다릅니다. 타사 라이브러리 작성자 (자신의 조직에서 라이브러리를 사용하는 것과는 다른)는 Pimpl을 많이 선호 할 수 있습니다.
Spacen Jasset 2018

31

Pointer to implementation일반적으로 구조적 구현 세부 사항을 숨기는 것입니다. Interfaces다른 구현을 인스턴스화하는 것입니다. 그들은 실제로 두 가지 다른 목적을 제공합니다.


13
반드시 필요한 것은 아니지만 원하는 구현에 따라 여러 개의 pimpl을 저장하는 클래스를 보았습니다. 종종 이것은 플랫폼마다 다르게 구현되어야하는 무언가의 win32 impl과 linux impl을 말합니다.
Doug T.

14
그러나 인터페이스를 사용하여 구현 세부 사항을 분리하고 숨길 수 있습니다.
Arkaitz Jimenez

6
인터페이스를 사용하여 pimpl을 구현할 수 있지만 구현 세부 정보를 분리 할 이유가없는 경우가 많습니다. 따라서 다형성이 될 이유가 없습니다. pimpl 의 이유 는 구현 세부 사항을 클라이언트에서 멀리 유지하기 위함 입니다 (C ++에서 헤더에서 제외). 추상 기반 / 인터페이스를 사용하여이 작업을 수행 할 수 있지만 일반적으로 불필요한 과잉입니다.
Michael Burr

10
왜 과잉입니까? 내 말은, 인터페이스 방법이 pimpl보다 느린가요? 논리적 이유가있을 수 있지만, 실용적인 관점에서 보면 추상 인터페이스를 사용하는 것이 더 쉽다고 말하고 싶습니다
Arkaitz Jimenez

1
추상 기본 클래스 / 인터페이스가 작업을 수행하는 "정상적인"방법이며 조롱을 통해 더 쉽게 테스트 할 수 있도록합니다
paulm

28

pimpl 관용구는 특히 대규모 응용 프로그램에서 빌드 종속성과 시간을 줄이는 데 도움이되며 하나의 컴파일 단위에 대한 클래스 구현 세부 정보의 헤더 노출을 최소화합니다. 클래스의 사용자는 여드름의 존재를 인식 할 필요조차 없어야합니다 (개인이 아닌 비밀 포인터를 제외하고는!).

추상 클래스 (순수 가상)는 클라이언트가 알고 있어야하는 것입니다. 커플 링 및 순환 참조를 줄이기 위해이를 사용하려는 경우 개체를 생성 할 수있는 방법을 추가해야합니다 (예 : 팩토리 메서드 또는 클래스를 통해 의존성 주입 또는 기타 메커니즘).


17

나는 같은 질문에 대한 답을 찾고 있었다. 몇 가지 기사와 연습을 읽은 후 "Pure virtual class interfaces"사용을 선호합니다 .

  1. 그들은 더 직설적입니다 (이것은 주관적인 의견입니다). Pimpl 관용구는 내 코드를 읽을 "다음 개발자"가 아니라 "컴파일러를위한"코드를 작성하고 있다는 느낌을줍니다.
  2. 일부 테스트 프레임 워크는 순수 가상 클래스 모의를 직접 지원합니다.
  3. 외부에서 접근 할 수있는 공장 이 필요한 것은 사실입니다 . 그러나 다형성을 활용하려면 "단점"이 아니라 "프로"이기도합니다. ... 간단한 공장 방법은 그렇게 많이 아프지 않습니다.

유일한 단점은 ( 나는 이것에 대해 조사하려고합니다 ) pimpl 관용구가 더 빠를 수 있다는 것입니다

  1. 프록시 호출이 인라인 될 때 ​​상속하는 동안 런타임에 객체 VTABLE에 대한 추가 액세스가 필연적으로 필요합니다.
  2. pimpl public-proxy-class의 메모리 공간이 더 작습니다 (빠른 스왑 및 기타 유사한 최적화를 위해 쉽게 최적화 할 수 있음)

21
또한 상속을 사용하면 vtable 레이아웃에 대한 종속성이 도입된다는 점을 기억하십시오. ABI를 유지하기 위해 더 이상 가상 기능을 변경할 수 없습니다 (자체 가상 메서드를 추가하는 자식 클래스가없는 경우 끝에 추가하는 것이 안전함).
Paul de Vrieze 2011

1
^ 여기에있는이 코멘트는 끈적 끈적해야합니다.
CodeAngry 2014

10

나는 여드름이 싫어! 그들은 못생긴 수업을하고 읽을 수 없습니다. 모든 방법은 여드름으로 리디렉션됩니다. 어떤 기능이 클래스에 있는지 헤더에서 볼 수 없으므로 리팩토링 할 수 없습니다 (예 : 단순히 메서드의 가시성을 변경). 수업은 "임신"한 느낌입니다. 나는 iterfaces를 사용하는 것이 더 좋고 실제로 클라이언트로부터 구현을 숨기기에 충분하다고 생각합니다. 한 클래스가 여러 인터페이스를 구현하여 씬을 유지할 수 있습니다. 인터페이스를 선호해야합니다! 참고 : 팩토리 클래스는 필요하지 않습니다. 관련성은 클래스 클라이언트가 적절한 인터페이스를 통해 인스턴스와 통신한다는 것입니다. 내가 이상한 편집증으로 여기고 인터페이스를 가지고 있기 때문에 이유를 보지 못하는 사적인 방법의 숨기기.


1
순수 가상 인터페이스를 사용할 수없는 경우가 있습니다. 예를 들어 레거시 코드가 있고 두 모듈을 건드리지 않고 분리해야하는 경우입니다.
AlexTheo

@Paul de Vrieze가 아래에서 지적했듯이 클래스의 vtable에 대한 암시 적 종속성이 있기 때문에 기본 클래스의 메서드를 변경할 때 ABI 호환성을 잃게됩니다. 이것이 문제인지 여부는 사용 사례에 따라 다릅니다.
H. Rittich

"이상한 편집증으로 발견 한 개인 메서드의 숨기기"종속성을 숨길 수 있으므로 종속성이 변경 될 경우 컴파일 시간을 최소화 할 수 있습니까?
pooya13

또한 팩토리가 pImpl보다 리팩토링하기가 더 쉬운 방법을 이해하지 못합니다. 두 경우 모두 "인터페이스"를 떠나 구현을 변경하지 않습니까? Factory에서는 하나의 .h 및 하나의 .cpp 파일을 수정해야하며 pImpl에서는 하나의 .h 및 두 개의 .cpp 파일을 수정해야하지만 그에 관한 것이며 일반적으로 pImpl 인터페이스의 cpp 파일을 수정할 필요가 없습니다.
pooya13

8

공유 라이브러리에는 순수 가상이 할 수없는 pimpl 관용구가 깔끔하게 우회하는 매우 실제적인 문제가 있습니다. 클래스 사용자가 코드를 다시 컴파일하도록 강요하지 않고는 클래스의 데이터 멤버를 안전하게 수정 / 제거 할 수 없습니다. 어떤 상황에서는 받아 들일 수 있지만 예를 들어 시스템 라이브러리에는 허용되지 않습니다.

문제를 자세히 설명하려면 공유 라이브러리 / 헤더에서 다음 코드를 고려하십시오.

// header
struct A
{
public:
  A();
  // more public interface, some of which uses the int below
private:
  int a;
};

// library 
A::A()
  : a(0)
{}

컴파일러는 A 객체에 대한 포인터에서 인 것으로 알고있는 A 객체에 대한 특정 오프셋 (이 경우 유일한 멤버이므로 0 일 가능성이 있음)이되도록 초기화 할 정수의 주소를 계산하는 공유 라이브러리의 코드를 내 보냅니다 this.

코드의 사용자 측에서 a new A는 먼저 sizeof(A)메모리 바이트를 할당 한 다음 해당 메모리에 대한 포인터를 A::A()생성자에 this.

라이브러리의 이후 개정판에서 정수를 삭제하거나, 더 크게, 더 작게 만들거나, 멤버를 추가하기로 결정하면 사용자의 코드가 할당하는 메모리 양과 생성자 코드가 예상하는 오프셋 사이에 불일치가 발생합니다. 운이 좋으면 충돌이 발생할 가능성이 있습니다. 운이 좋지 않으면 소프트웨어가 이상하게 작동합니다.

공유 라이브러리에서 메모리 할당 및 생성자 호출이 발생하므로 pimpl'ing을 통해 내부 클래스에 데이터 멤버를 안전하게 추가하고 제거 할 수 있습니다.

// header
struct A
{
public:
  A();
  // more public interface, all of which delegates to the impl
private:
  void * impl;
};

// library 
A::A()
  : impl(new A_impl())
{}

지금해야 할 일은 구현 객체에 대한 포인터 이외의 데이터 멤버가없는 공용 인터페이스를 유지하는 것뿐입니다.이 클래스의 오류로부터 안전합니다.

편집 : 여기서 생성자에 대해 이야기하는 유일한 이유는 더 많은 코드를 제공하고 싶지 않았기 때문일 수 있습니다. 동일한 인수가 데이터 멤버에 액세스하는 모든 함수에 적용됩니다.


4
void * 대신 구현 클래스를 전달하는 것이 더 전통적이라고 생각합니다.class A_impl *impl_;
Frank Krueger

9
이해가 안 돼요, 인터페이스로 사용하려는 가상 순수 클래스에서 private 멤버를 선언해서는 안됩니다. 아이디어는 클래스를 신중하게 추상화하고 크기없이 순수 가상 메서드 만 유지하는 것입니다. 아무것도 보이지 않습니다. 공유 라이브러리를 통해 할 수 없습니다
Arkaitz Jimenez

@Frank Krueger : 당신 말이 맞아요. @Arkaitz Jimenez : 약간의 오해; 순수 가상 함수 만 포함하는 클래스가 있다면 공유 라이브러리에 대해 이야기 할 필요가 없습니다. 반면에 공유 라이브러리를 다루는 경우 위에 설명 된 이유 때문에 공용 클래스를 pimpl하는 것이 신중할 수 있습니다.

10
이것은 단지 잘못된 것입니다. 두 메서드 모두 다른 클래스를 "순수 추상 기본"클래스로 만들면 클래스의 구현 상태를 숨길 수 있습니다.
Paul Hollingsworth

10
anser의 첫 번째 문장은 연관된 팩토리 메소드가있는 순수 가상이 클래스의 내부 상태를 숨기도록 허용하지 않는다는 것을 의미합니다. 그건 사실이 아니야. 두 기술 모두 클래스의 내부 상태를 숨길 수 있습니다. 차이점은 사용자에게 보이는 방식입니다. pImpl을 사용하면 값 의미론으로 클래스를 표시하면서도 내부 상태를 숨길 수도 있습니다. 순수 추상 기본 클래스 + 팩토리 메서드를 사용하면 엔티티 유형을 나타낼 수 있으며 내부 상태를 숨길 수도 있습니다. 후자는 정확히 COM이 작동하는 방식입니다. "Essential COM"의 1 장에서는 이에 대해 큰 토론을했습니다.
Paul Hollingsworth

6

우리는 상속이 위임보다 더 강력하고 긴밀한 결합임을 잊지 말아야합니다. 또한 특정 문제를 해결하는 데 사용할 디자인 관용구를 결정할 때 주어진 답변에서 제기 된 모든 문제를 고려합니다.


3

다른 답변에서 광범위하게 다루어 지지만 가상 기본 클래스에 비해 pimpl의 한 가지 이점에 대해 조금 더 명시 할 수 있습니다.

pimpl 접근 방식은 사용자 관점에서 투명합니다. 즉, 예를 들어 스택에 클래스의 개체를 만들고 컨테이너에서 직접 사용할 수 있습니다. 추상 가상 기본 클래스를 사용하여 구현을 숨기려고하면 팩토리에서 기본 클래스에 대한 공유 포인터를 반환해야하므로 사용이 복잡해집니다. 다음과 같은 클라이언트 코드를 고려하십시오.

// Pimpl
Object pi_obj(10);
std::cout << pi_obj.SomeFun1();

std::vector<Object> objs;
objs.emplace_back(3);
objs.emplace_back(4);
objs.emplace_back(5);
for (auto& o : objs)
    std::cout << o.SomeFun1();

// Abstract Base Class
auto abc_obj = ObjectABC::CreateObject(20);
std::cout << abc_obj->SomeFun1();

std::vector<std::shared_ptr<ObjectABC>> objs2;
objs2.push_back(ObjectABC::CreateObject(13));
objs2.push_back(ObjectABC::CreateObject(14));
objs2.push_back(ObjectABC::CreateObject(15));
for (auto& o : objs2)
    std::cout << o->SomeFun1();

2

내 이해에서이 두 가지는 완전히 다른 목적으로 사용됩니다. 여드름 관용구의 목적은 기본적으로 구현에 대한 핸들을 제공하여 일종의 빠른 스왑과 같은 작업을 수행 할 수 있도록하는 것입니다.

가상 클래스의 목적은 다형성을 허용하는 것입니다. 즉, 파생 된 유형의 객체에 대한 알 수없는 포인터가 있고 함수 x를 호출 할 때 기본 포인터가 실제로 가리키는 클래스에 대해 항상 올바른 함수를 얻습니다.

정말 사과와 오렌지.


나는 사과 / 오렌지에 동의합니다. 그러나 기능에 pImpl을 사용하는 것으로 보입니다. 제 목표는 대부분 기술과 정보를 숨기는 것입니다.
xtofl 2009

2

pimpl 관용구의 가장 성가신 문제는 기존 코드를 유지하고 분석하기가 극도로 어렵다는 것입니다. 따라서 pimpl을 사용하면 "빌드 종속성 및 시간을 줄이고 구현 세부 사항의 헤더 노출을 최소화"하기 위해서만 개발자 시간과 좌절감을 지불합니다. 그만한 가치가 있다면 스스로 결정하십시오.

특히 "빌드 시간"은 더 나은 하드웨어 나 Incredibuild (www.incredibuild.com, Visual Studio 2017에 이미 포함되어 있음)와 같은 도구를 사용하여 해결할 수있는 문제이므로 소프트웨어 디자인에 영향을주지 않습니다. 소프트웨어 디자인은 일반적으로 소프트웨어 빌드 방식과 독립적이어야합니다.


또한 빌드 시간이 2 분이 아닌 20 분일 때 개발자 시간으로 지불하므로 약간의 균형이 잡히면 실제 모듈 시스템이 여기에서 많은 도움이 될 것입니다.
Arkaitz Jimenez

IMHO, 소프트웨어 구축 방식은 내부 설계에 전혀 영향을주지 않아야합니다. 이것은 완전히 다른 문제입니다.
Trantor

2
분석을 어렵게 만드는 것은 무엇입니까? Impl 클래스로 전달되는 구현 파일의 많은 호출은 어렵게 들리지 않습니다.
mabraham

2
pimpl과 인터페이스가 모두 사용되는 디버깅 구현을 상상해보십시오. 사용자 코드 A의 호출에서 시작하여 인터페이스 B로 추적하고, pimpled 클래스 C로 이동하여 마침내 구현 클래스 D 디버깅을 시작합니다. 실제로 발생하는 상황을 분석 할 수있을 때까지 네 단계를 수행합니다. 그리고 모든 것이 DLL에서 구현되면 아마도 중간 어딘가에서 C 인터페이스를 찾을 수 있습니다 ....
Trantor

pImpl이 인터페이스 작업을 수행 할 수 있는데 왜 pImpl과 함께 인터페이스를 사용합니까? (즉, 종속성 반전을 달성하는 데 도움이 될 수 있음)
pooya13
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.