ออกแบบ class สำหรับ resource ใน C++11

เวลาเราเขียนคลาสง่าย ๆ สำหรับการอ่าน resource จากไฟล์ขึ้นมาเพื่อจะใช้อะไรบางอย่าง ถ้าคิดง่าย ๆ เราอาจจะเขียนแบบนี้ …

class Image {
public:
    Image(const std::string& path);
    ~Image();
private:
    Image::Image(){}
    char* buffer;
}

Image::Image() {
    buffer = new char[file_size(path)];
    fill(path, buffer.get(), file_size(path));
}

Image::~Image() {
    delete[] buffer;
}

มองผ่าน ๆ ก็ดูเหมือนจะไม่มีปัญหาอะไร โดยเฉพาะกับคนที่เขียนภาษาอย่าง Java หรือ C# มาก่อนจะดูไม่ออกเลย ปัญหาอยู่ใน constructor ครับ คือ ในบรรทัดแรกเรามีการสร้าง dynamic array ขึ้นมาหนึ่งตัว แล้วเราก็ fill บัฟเฟอร์ลงไปในบรรทัดที่สอง ถ้าเกิดว่าบรรทัดที่ 2 นั้นมี exception เกิดขึ้นล่ะจะเกิดอะไรขึ้น ?

คำตอบคือ constructor จะหยุดทำงานและ stack จะถูก unwind ขึ้นไปเรื่อย ๆ จนกว่า exception นั้นจะถูกจับด้วย try...catch ซึ่งตัวแปรที่อยู่ใน stack จะทำลายทิ้งไปหมด

สิ่งที่ขาดหายไปก็คือ desctructor ของคลาสนี้นั้นจะไม่ถูกเรียกเลยในกรณีนี้ และหน่วยความจำส่วนที่ถูกจองมาสำหรับ Image::buffer ก็จะยังคงอยู่อย่างนั้น เป็นหน่วยความจำที่รั่วต่อไป

นี่แค่ตัวแปรตัวเดียวนะครับ ถ้าเกิดมีมากกว่านี้ก็วุ่นวายกว่านี้อีก

คำแนะนำทั่วไปสำหรับวิธีนี้คือ ตัว constructor ไม่ควรมีโค๊ดที่โยน exception ได้ … กลายเป็นว่าเวลาเขียนก็จะวุ่นวาย กลายเป็นต้องมาสร้าง factory method อีก แบบนี้

class Image {
public:
    Image * Load(const std::string& path);
    ~Image();
private:
    Image::Image(){}
    char* buffer;
}

Image Image::Load(const std::string& path) {
    char* buffer = new char[file_size(path)];
    try {
        fill(path, buffer.get(), file_size(path));
    } catch (...) {
        delete[] buffer;
        throws std::runtime_error("Error");
    }
    Image output;
    output.buffer = buffer;

    return output;
}

Image::~Image() {
    delete[] buffer;
}

ข้อเสียของวิธีนี้คือ ยิ่งคุณมี resource ที่อยู่ใน heap มากขึ้น โค๊ดจะยิ่งรกและเละมากขึ้นเรื่อย ๆ โดยเฉพาะตัว Factory Method

สมมติว่าคลาส Image ผมดันมีข้อมูล palette ด้วย

class Image {
public:
    Image * Load(const std::string& path);
    ~Image();
private:
    Image::Image(){}
    char* buffer;
    char* palette;
}

Image Image::Load(const std::string& path) {
    char* buffer = new char[file_size(path)];
    try {
        fill(path, buffer.get(), file_size(path));
    } catch (...) {
        delete[] buffer;
        throws std::runtime_error("Error");
    }
    char *palette = new char[palette_size()];
    try {
        fill_palette(path, palette.get, palette_size(path));
    } catch (...) {
        delete[] palette;
        delete[] buffer;
        throws std::runtime_error("Error");
    }
    Image output;
    output.buffer = buffer;
    output.palette = palette;

    return output;
}

Image::~Image() {
    delete[] buffer;
}

พอผมเพิ่มตัวแปรทีเ่ป็น dynamic array เพิ่มหนึ่งตัว ผมต้องเขียนโค๊ดที่คืนหน่วยความจำที่ชี้ไปโดยตัวแปรตัวก่อนหน้าด้วย (ดังนั้นมันจะเพิ่มแบบ exponential) และยิ่งเพิ่มมากโค๊ดส่วนที่เพิ่มก็จะขยายขึ้นเรื่อย ๆ ดูปวดหัวใช่ไหมครับ ?

วันนี้จะเสนออีกวิธีครับ นั่นก็คือการใช้ smart pointer นั่นเอง (อีกแล้ว) คลาสด้านบนผมจะเขียนให้อยู่ในรูปของ C++11 ได้แบบนี้

class Image {
public:
     Image(const std::string& path);
     Image::Image() = delete;

private:
    std::shared_ptr<char> buffer;
    std::shared_ptr<char> palette;
}

Image::Image(const std::string& path) {
    buffer = std::shared_ptr<char>(
            new char[file_size(path)],
            [](char* array){
                delete [] array;
            });

    fill(path, buffer.get(), file_size(path));

    palette= std::shared_ptr<char>(
            new char[palette_size()],
            [](char* array){
                delete [] array;
            });
    fill_palette(path, palette.get, palette_size(path));
}

สังเกตไหมครับว่า เวลาผมเพิ่มตัวแปรตัวใหม่เข้ามา ผมไม่ต้องไปยุ่งอะไรกับตัวแปรก่อนหน้านั้น ทุกอย่างถูกจัดการโดยตัว smart pointer เอง และเวลาที่มี exception โยนเข้ามาใน constructor ตัวแปรที่เป็น smart pointer นั้นจะเข้าไปคืนหน่วยความจำส่วนที่มันชี้ไปด้วย ดังนั้นผมจึงไม่ต้องไปยุ่งอะไรกับมันอีก ผมสามารถลบ destructor ทิ้งไปได้ด้วยซ้ำ

ทีนี้น่าจะมีคนสงสัยว่าทำไมผมต้องใส่ lambda ตรงที่ผม assign ค่าตัว smart pointer ทั้งหลาย ? คืองี้ครับ โดยปรกติแล้ว เวลาที่ smart pointer จะไปคืนหน่วยความจำส่วนที่จองมาใน heap เนี่ย มันจะใช้ delete operator ครับ ซึ่งถ้าไม่ใส่ lambda ที่ผมระบุไว้เนี่ย การคืนมันจะกลายเป็น delete buffer ซึ่งมันจะคืนแค่ตัวแรกสุดตัวเดียว และส่วนที่เหลือก็จะกลายเป็นหน่วยความจำรั่วไป lamba ที่ผมใส่เอาไว้เป็นการไป override วิธีการคืนหน่วยความความจำของเจ้า smart pointer ตัวนี้ (เรียกว่า deleter) โดยผมไปสั่งให้มันใช้ delete[] operator แทน ทำให้สามารถเรียกคืนหน่วยความจำมาได้หมด

เราสามารถใช้วิธีเดียวกันกับหน่วยความจำที่จองโดยคำสั่งพิเศษของ Library อื่น ๆ ที่เราใช้ได้ด้วย อย่างเช่น

Music::Music(const std::string& path)
{
    Mix_Music * pMusic = Mix_LoadMUS(path.c_str());
    if(!pMusic)
    {
        auto error =
                std::string("Load Music Failed : ") +
                Mix_GetError();

        throw std::runtime_error(error);
    }

    mixMusic = std::shared_ptr<Mix_Music>(
                pMusic,
                [](Mix_Music* pMusic) {
                    Mix_FreeMusic(pMusic);
                });
}

อันนี้เป็นคลาสเพลงที่เรียกใช้คำสั่งจาก SDL_mixer ครับ

ใส่ความเห็น

อีเมลของคุณจะไม่แสดงให้คนอื่นเห็น ช่องข้อมูลจำเป็นถูกทำเครื่องหมาย *

This site uses Akismet to reduce spam. Learn how your comment data is processed.