>>ทำความรู้จัก Pimpl ใน C++

ทำความรู้จัก Pimpl ใน C++

By | 2018-07-02T11:40:44+00:00 July 1st, 2018|Programming|0 Comments

เกริ่นก่อน

ใน C++ หากว่าเวลา header file (.h, .hpp) ถูกแก้ไข มันจะทำให้ memory layout เปลี่ยน  ทำให้ source file (.cpp) ทุกอันที่เรียกใช้งาน header file ต้องทำการคอมไพล์ใหม่ทั้งหมด

ยกตัวอย่าง  สมมุติว่าเราเขียน library ให้อีกทีมนึงใช้งานหรือว่าเราใช้งานเองก็ตาม  มีคลาส sprite ดังนี้

#pragma once

#include <memory>
#include <string>
#include "Vector2.h"

class Renderer;
class Texture;

class Sprite
{
public:

  Sprite();
  ~Sprite();

  bool Load(const std::string& fileName);
  void SetPosition(float x, float y);
  void Draw();

private:

  Vector2 mPosition;
  Vector2 mVertices[4];
  std::shared_ptr<Texture> mTexture;
  std::shared_ptr<Renderer> mRenderer;
};

อีกทีมนำไปใช้เพื่อแสดงผลภายในเกม

// Tank.h
#pragma once

#include <memory>
#include "Sprite.h"
#include "GameObject.h"

class Tank : public GameObject
{
public:

  virtual bool LoadAsset() override;
  virtual void Fire() override;

private:

  std::shared_ptr<Sprite> mBody;
  std::shared_ptr<Sprite> mBarrel;
};
// HelicopterGunship.h
#pragma once

#include <memory>
#include "GameObject.h"
#include "Sprite.h"

class HelicopterGunship : public GameObject
{
public:

  virtual bool LoadAsset() override;
  virtual void Fire() override;

private:

  std::shared_ptr<Sprite> mBody;
  std::shared_ptr<Sprite> mMainRotor;
  std::shared_ptr<Sprite> mTailRotor;
};

ระหว่างพัฒนาเรามีการแก้ไขคลาส Sprite ให้ดีขึ้นโดยเปลี่ยนจาก Renderer ธรรมดามาเป็น BatchRenderer

#pragma once

#include <memory>
#include <string>
#include "Vector2.h"

class BatchRenderer;
class Texture;

class Sprite
{
public:

  Sprite();
  ~Sprite();

  bool Load(const std::string& fileName);
  void SetPosition(float x, float y);
  void Draw();

private:

  Vector2 mPosition;
  Vector2 mVertices[4];
  std::shared_ptr<Texture> mTexture;
  std::shared_ptr<BatchRenderer> mRenderer;
};

ทีนี้ส่งต่อให้อีกทีมหรือใช้เองก็ตาม  ทุก source file (.cpp) ที่มีการเรียกใช้งาน sprite.h ต้องคอมไพล์ใหม่ทั้งหมด (ไม่ว่าจะเอา source code มาคอมไพล์เองหรือเอามาแค่ header และ binary (.lib, .dll)  จากตัวอย่างมันก็คงไม่ได้ใช้เวลานานอะไรมากเท่าไหร่ แต่สมมุตว่ามีอะไรแบบนี้อยู่เยอะๆ มันก็เสียเวลาบ้างนิดหน่อย

ด้วยเหตุนี้ (อาจจะไม่ใช่เหตุผลหลัก) บาง library จึงมีการใช้เทคนิคที่เรียกว่า PIMPL (Pointer to IMPLementation) โดยการซ่อนรายละเอียดต่างๆไว้อีกคลาสนึงดังนี้

// Sprite.h
#pragma once

#include <memory>
#include <string>
#include "Vector2.h"

class Renderer;
class Texture;

class Sprite
{
public:

  Sprite();
  ~Sprite();

  bool Load(const std::string& fileName); // Support only PNG, JPG.
  void SetPosition(float x, float y);
  void Draw();

private:

  class SpriteImpl;
  std::unique_ptr<SpriteImpl> mSpriteImpl;
};

สังเกตุว่าเราประกาศ forward class declaration (SpriteImpl) ไว้ในคลาสที่เป็นส่วน private  และไปประกาศตัวแปรต่างๆ (หรืออาจจะมี method อยู่ด้วยก็ได้ ไม่จำเป็นต้องมีแต่เฉพาะตัวแปร) ในนั้นอีกทีไว้ในตัวไฟล์ Sprite.cpp

// Sprite.cpp
#include "Sprite.h"
#include "Renderer.h"
#include "Texture.h"

class Sprite::SpriteImpl
{
public:

  Vector2 Position;
  Vector2 Vertices[4];
  std::shared_ptr<Texture> Texture;
  std::shared_ptr<Renderer> Renderer;
};

Sprite::Sprite()
{
  mSpriteImpl = std::unique_ptr<SpriteImpl>(new SpriteImpl());
  mSpriteImpl->Renderer = std::shared_ptr<Renderer>(new Renderer());
}


bool Sprite::Load(const std::string & fileName)
{
  mSpriteImpl->Texture = std::shared_ptr<Texture>(new Texture());
  mSpriteImpl->Texture->Load(fileName);

  return true;
}

ทีนี้ถ้าหากเราทำเปลี่ยนเป็นไปใช้ BatchRenderer ก็เปลี่ยนได้เลยใน class Sprite::SpriteImpl

// Sprite.cpp
#include "Sprite.h"
#include "BatchRenderer.h"
#include "Texture.h"

class Sprite::SpriteImpl
{
public:

  Vector2 Position;
  Vector2 Vertices[4];
  std::shared_ptr<Texture> Texture;
  std::shared_ptr<BatchRenderer> Renderer;
};

Sprite::Sprite()
{
  mSpriteImpl = std::unique_ptr<SpriteImpl>(new SpriteImpl());
  mSpriteImpl->Renderer = std::shared_ptr<BatchRenderer>(new BatchRenderer());
}

Sprite::~Sprite()
{
}

bool Sprite::Load(const std::string & fileName)
{
  mSpriteImpl->Texture = std::shared_ptr<Texture>(new Texture());
  mSpriteImpl->Texture->Load(fileName);

  return true;
}

ตราบใดที่ header file (.h, .hpp) ยังเหมือเดิมทุกประการ พอเราทำการคอมไพล์ คอมไพเลอร์ก็จะคอมไพล์แค่ Sprite.cpp และส่วนอื่นๆ (เช่น BatchRenderer.cpp) ที่ยังไม่ได้คอมไพล์เท่านั้น  !!!

ไปแอบส่อง library ที่มีการใช้เทคนิคนี้

ผมเอา 3 ตัวอย่างที่ผมจำได้และเห็นจะๆคาตามาให้ดู

  • QT สุดยอดคลาสิค C++ GUI Library, ในเอกสารของ QT เองจะเรียกว่า D-Pointer
  • AssImp เป็น library สำหรับการโหลดพวกโมเดล 3D  ตัวอย่างการใช้งาน Pimpl
  • ClanLib เป็น game library ตัวนึง ตัวอย่างการใช้งาน Pimpl (ทำให้ sprite เป็น pimp เหมือนกับตัวอย่างที่ผมยกขึ้นมา)

อย่างนี้เราก็ควรเปลี่ยนมาใช้ให้หมดดีไหม?

เดี๋ยว… เดี๋ยวก่อนเลย  มันไม่ได้เหมาะกับทุกโปรเจ็ค และไม่เหมาะกับทุกคลาส หากว่าเราไม่ได้เป็นคนที่เขียน library ที่เป็นประเภท 3rd party แล้วละก็  ผมแนะนำอย่างยิ่งว่าไม่ควรใช้

ทำไมละ?

  • การทำแบบที่ว่ามันเพิ่มชั้น (layer) ของคลาสขึ้นมาโดยไม่ค่อยจำเป็น ลองนึกดูว่าคลาส Tank ของเราจะต้องมาทำเป็นแบบ Pimpl เหมือนกับคลาส Sprite  ก็ลองคิดดูว่าเราไม่ได้ทำ library ไปให้ใครใช้ทำไมต้องมาทำอะไรให้มันซับซ้อนโดยไม่จำเป็น
  • หน้าตา source file (.cpp) จะน่าเกลียดขึ้น (สำหรับบางคน) เพราะต้องมานั่งเรียก Pimp->Member->DoSomething();
  • Debug ก็ลำบากกว่าปกติ อันเนื่องมาจากการมีชั้น (layer) เพิ่มขึ้น
  • ลองนึกสภาพคลาส Rect ที่มีการใช้ Pimpl ดูสิ! (ถึงได้บอกไม่เหมาะกับทุกคลาส)

ถ้าหากว่าสังเกตุ จะพบว่า 3 โปรเจ็คตัวอย่างที่ผมบอกไป เป็นโปรเจ็คประเภท 3d party library หมดเลย  มันก็เหมาะสมดี แต่เริ่มต้นการใช้ pimpl ของแต่ละโปรเจ็คก็ไม่เหมือนกันซะทีเดียว อย่างเช่น

  • QT แรกเริ่มไม่ได้เป็น Open Source จึงใช้ Pimpl เพื่อปกปิดรายละเอียดของ library และนอกจากนี้มันยังทำให้การอัพเกรด library ง่ายต่อผู้ใช้งาน
  • AssImp เป็น Open Source แต่แรก แต่ใช้ Pimpl เพื่อไม่ให้ public header ต้องเรียก (include) พวก standard library

ในยุคที่คอมพิวเตอร์แรงขนาดนี้ไม่ต้องกังวลเวลาคอมไพล์ที่นานขึ้นนิดหน่อยจากการแก้ไข header file (.h, .hpp)

ปิดโพส

ที่เขียนโพสนี้ขึ้นมา ไม่ได้มาแนะนำให้ใช้(อ้าว) แค่ให้รู้ว่ามันมีอยู่ และเรียกว่าอะไร

Ogre3D, Unreal Engine ยังไม่ใช้เลย…

 

ไว้มีโอกาศจะมาเขียนอีกเรื่องที่เป็นเรื่องใกล้เคียงกันและผมเคยใช้อย่างจริงจัง  เมื่อตอนเขียน Game Engine นั่นก็คือเรื่อง Abstract Interface