เกริ่นก่อน
ใน 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