ออกแบบ 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 ครับ

code indentation : tab vs space

วันนี้จั่วหัวเป็นภาษาอังกฤษ เพราะถ้าใส่ภาษาไทย มันคงมีอยู่ไม่กี่คำหรอกครับ

Code indentation ก็คือการใส่ที่ว่างหน้าแต่ละบรรทัดของโค้ด เพื่อที่จะระบุว่าโค๊ดปัจจุบันอยู่ในสโคประดับไหน บนคีย์บอร์ดเราจะสามารถใส่ที่ว่างได้สองวิธี นั่นคือการใช้ space bar กับการใช้ tab

ผมคิดว่าทุกคนคงรู้นะว่าปุ่มไหนอยู่ตรงไหน

เรื่อง space กับ tab นี่ถือได้ว่า เป็นเรื่องที่ทำให้คนทะเลาะกันได้ เวลาที่จะกำหนด coding standard หรือทำ code review เพราะทั้งสองอย่างก็มีข้อดีของมันเอง

ส่วนตัวคิดว่า จะใช้แบบไหนก็ใช้ไปเถอะ ที่สำคัญกว่าก็คือต้องใช้เหมือนกันหมดทุกคน และใช้อย่างสม่ำเสมอ ไม่เช่นนั้นมันก็จะเกิดปัญหาหลายอย่างขึ้นมาเหมือนกัน

แต่ถ้าให้เลือกว่าเอาแบบไหน ผมเลือกใช้ space นะ

คืองี้ครับ ในโปรแกรมแต่ละโปรแกรม จะแสดงผล tab ไม่เหมือนกัน บางโปรแกรมก็ 3 space มั่งล่ะ 4 space มั่งล่ะ (8 space ยังไม่แปลกเลย) แถมยังเปลี่ยนได้ด้วย ดังนั้นเวลาเปิดบนแต่ละโปรแกรมโค๊ดจะแสดงผลไม่เหมือนกัน

และในกรณีที่แย่ที่สุดคือถ้ามีคนเอา space กับ tab ไปใช้ปนกันในโค๊ดเดียว ในทุก ๆ whitespace บนโค๊ด เราจะเห็นโค๊ดที่ไม่ตรงกันในโปรแกรมหนึ่ง และดูสวยในอีกโปรแกรมหนึ่ง

ในทางกลับกัน space นั้นแสดงผลเหมือนกันหมดทุกเครื่อง ไม่ว่ามันจะอยู่ที่ไหน ไม่ว่าจะอยู่บนแพลตฟอร์มไหน ดังนั้น space จึงเป็นคำตอบที่ดีกว่า เพราะว่ามันมีความสม่ำเสมอ (consistency) ที่ดีกว่า tab นั่นเอง

แต่พิมพ์เยอะแล้วอาจจะเมื่อย ก็มีเคล็ดนิดหน่อยครับ คือ ใน Text Editor ส่วนใหญ่สามารถแทนที่ tab ด้วย space ได้ คือเวลากด tab แทนที่จะได้ tab เราก็จะได้ space จำนวนหนึ่งแทน

ไม่ยากกันเกินไปใช่ไหมครับ 🙂

ปัญหา memory leak บน C++ ส่วนใหญ่เกิดจากภาษา Java

หลายคนคงคิดว่า มันไปเกี่ยวอะไรกับ Java ? จะบอกว่า C++ ไปเรียกใช้ Java ก็เห็นจะไม่ใช่ แล้วมันเกี่ยวยังไง ? ภาษา Java เกิดมาหลัง C++ ด้วยซ้ำไป

ที่จริงมันก็ไม่เกี่ยวกับ Java หรอกครับ ที่ผมจะบอกคือ มีหลายคน (มาก) ที่เขียนภาษา C++ ด้วยวิธีแบบ Java ซึ่งมักจะเกิดกับคนที่เรียน Java มาก่อนหัดเขียน C++ น่ะครับ

ยกตัวอย่าง ใน Java เราจะสร้าง instance ของคลาสด้วยการใช้คำสั่ง new แบบนี้

Object obj = new Object();

แล้วอยากจะใช้อะไรก็ใช้ไปเลย ใช้เสร็จแล้วก็ปล่อยมันไว้อย่างนั้น แล้ว garbage collector ก็จะมาจัดการเองเมื่อวัตถุนั้น ๆ ไม่ได้ถูกใช้งานแล้ว

ใน C++ เองก็มีการใช้ new เหมือนกัน (ความจริง new operator ของ Java ก็มาจาก C++ นี่ล่ะ) เพียงแต่ว่าใน C++ นั้นไม่มี garbage collector ดังนั้นผู้ใช้ก็จะต้องลบ object นั้น ๆ ทิ้งด้วยตัวเอง ด้วย delete operator แบบนี้

Object *obj = new Object();
...
delete obj;

แล้วจะเกิดอะไรขึ้นถ้าเราลืมเรียก delete ? คำตอบคือ object นั้นก็จะลอยอยู่ใน heap จนกว่าโปรแกรมจะปิดตัวไปนั่นล่ะครับ (หรือที่แย่กว่าก็จนกว่าจะปิดเครื่อง)อาการนี้เรียกว่า “memory leak” คือการที่หน่วยความจำใน heap ถูกจองไปแล้วไม่สามารถนำกลับมาใช้ได้อีกถึงแม้ว่ามันจะไม่ถูกอ้างถึงแล้ว

กลับไปยัง Java อีกที ใน Java เราจะสร้าง array ด้วย new operator เช่นกัน เพียงแต่จะใส่ [] เอาไว้หลัง type ด้วย แบบนี้

Object[] arrayObj = new Object[10];

อะไรทำนองนี้ ใน C++ ก็เขียนคล้ายกันได้เช่นกัน แบบนี้

Object* arrayObj = new Object[10];
...
delete[] arraryObj;

ทั้งนี้ต้องอย่างลืมว่า สร้างด้วย new [] ก็ต้องลบด้วย delete[] ด้วยนะครับ เหตุผลคือ ถ้าเราไม่ใส่ [] มันจะไปทำลาย Object แรกสุดในอะเรย์เท่านั้น

อ่านจนถึงบรรทัดนี้หลาย ๆ คนคิดว่า เออมันก็ดูไม่มีปัญหาอะไรนี่ ? ใช่ครับมันไม่มีปัญหาอะไร ถ้าทำทุกอย่างถูกต้อง ปัญหามันจะมาตอนลืมใส่ delete เลือกคำสั่งผิด (ดันใช่ delete แทน delete[]) หรือแม้กระทั่งคำสั่ง delete นั้นไม่ถูกเรียก ลองดูโค๊ดนี้

Socket *pSocket = new socket(80);
pSocket->connect();
....
delete pSocket;

ถามว่า ถ้าเกิดคำสั่ง pSocket->connect() ดันโยน exception ออกมา จะเกิดอะไรขึ้น ง่าย ๆ ครับ บรรทัด delete pSocket; ก็จะไม่ถูกเรียก และหน่วยความจำส่วนของ pSocket ก็จะรั่วไป (เผลอ ๆ พอร์ท 80 จะถูกเปิดค้างไว้ด้วยนะคราวนี้)

ก็ต้องครอบ try/catch อีก กลายเป็น

Socket *pSocket = new socket(80);
try {
pSocket->connect();
....
} catch (...) {
    delete pSocket;
    pSocket = nullptr;
}

if(pSocket) delete pSocket;

วิธีประกาศตัวแปรแบบพื้นฐาน

อ่านถึงตรงนี้ก็คงคิดว่า โห C++ ยากจัง …. ทำไมมันวุ่นวายจัง หลาย ๆ คนน่าจะจับได้แล้วว่าปัญหามันอยู่ตรงไหน โค๊ดข้างบนทั้งหมดเป็นความผิดพลาดของมือใหม่ C++ เลยล่ะครับ คืองี้ครับ ใน C++ วิธีการสร้าง instance ของ class นั้นเราเขียนแบบนี้ครับ

Object obj;

และการสร้าง array ก็เขียนแค่

Object arrayObj[10];

เท่านั้นเอง ตัวแปรชุดนี้ถูกสร้างขึ้นใน stack และเมื่อโปรแกรมรันจนสุด scope ที่ตัวแปรตัวนั้นทำงานอยู่ มันก็จะถูกทำลายไปเอง เราไม่ต้องไปยุ่งอะไรกับมัน

ส่วนโค๊ด socket ข้างบนน่ะเหรอ ? ก็เขียนแค่นี้เอง

Socket socket(80);
socket.connect();

… เนี่ย พอแล้ว เมื่อสิ้นสุดสโคปของโค้ด ไม่ว่าจะเพราะว่ารันจนถึงปีกกาปิด หรือมี exception โยนออกมา ตัวแปร socket จะถูกทำลายไปเองโดยอัตโนมัติ ไม่ต้องไปยุ่งอะไรกับมันเช่นกัน

คำสั่ง new

แล้วไอ้คำสั่ง new มันทำอะไรกันแน่ … คำสั่ง new คือการสร้างวัตถุใน heap แล้วคืนค่าที่อยู่กลับมาให้ เราก็เอา pointer ไปรับมันมา ซึ่งนั่นหมายถึงเราจะมีวัตถุสองชิ้น ชิ้นหนึ่งคือวัตถุใน heap อีกชิ้นหนึ่งคือ pointer ที่สร้างขึ้นเพื่อไปรับค่าที่คืนมาจาก delete

เจ้า pointer นี่ก็อยู่ใน stack เหมือนตัวแปรอื่น ๆ นี่แหละ ดังนั้นเมื่อรันไปหมด scope หรือเกิด exception ขึ้นมันก็จะถูกทำลายทิ้ง แต่วัตถุที่มันชี้ไปนั้นก็จะไม่ถูกทำลายตามไปด้วย ซึ่งผู้ที่เรียกใช้ new นั้นจะต้องรับผิดชอบในการเรียกใช้ delete(ไม่งั้นหน่วยความจำก็รั่ว)

และอะไรก็ตามที่มีคนเข้ามาเกี่ยวข้อง ก็จะเป็นจุดที่มักจะเกิดปัญหา ถูกไหมครับ ?

ข้อแนะนำตรงนี้คือ ถ้าไม่จำเป็น ก็อย่าไปเรียกใช้มัน เป็นการเลี่ยงปัญหาไม่ให้เกิด

แต่ถ้าจำเป็นล่ะ ? ถ้าสมมติเราใช้ polymorphism เยอะ ๆ (ซึ่งไม่สามารถใช้กับ stack variable ได้) อย่างเช่นเราเขียนคลาสพวก abstract factory ที่ต้องคืนค่าเป็น pointer ที่มีไทป์เป็น abstract class (ซึ่งไม่สามารถอยู่ได้ด้วยตัวมันเอง) วิธีที่ง่ายที่สุดคือใช้ smart pointer

Smart Pointer

Smart Pointer เป็น pointer ที่ฉลาดขึ้นมาอีกหน่อย กล่าวคือ มันรู้ว่ามันจะต้องไปทำลายวัตถุที่ตัวมันชี้ไปหาด้วยนั่นเอง

ใน C++11/14 นั้นจะมี Smart Pointer อยู่สองตัว ก็คือ std::shared_pointer กับ std::unique_pointer แต่ถ้าคุณไม่ได้ใช้ compiler ที่รองรับก็สามารถใช้ boost library ได้เช่นกัน (ก็จะเป็น boost::shared_pointer กับ boost::scoped_pointer ตามลำดับ)

สำหรับวิธีใช้นั้นก็ไม่ยาก ในตำแหน่งที่เราระบุประเภทของตัวแปร แทนที่เราจะใช้ pointer เปลือย ๆ ก็เอา smart pointer มารับเท่านั้นเอง เช่น

#include<memory>

Font* FontFactory::Load();
....
std::shared_pointer<Font*> font = FontFactory::Load(path);

หรือแบบนี้ก็ได้นะ เหมือนกัน

auto font = std::shared_pointer<Font*>(FontFactory::Load(path));

ความแตกต่างระหว่าง std::shared_pointer กับ std::unique_pointer ก็คือ เราสามารถสร้างสำเนาของ std::shared_pointer ได้ ในขณะที่เราทำแบบเดียวกันกับ std::unique_pointer ไม่ได้ นั่นหมายถึงคุณจะทำแบบข้างล่างไม่ได้

std::unique_ptr<int> u1 = new int(10);
std::unique_ptr<int> u2 = u1; //compile error;

ข้อแม้ของการใช้ smart pointer ใน standard libary ก็คือ ไทป์ของวัตถุที่เราจะสร้าง smart pointer นั้นจะต้องมี delete operator มารองรับด้วย ไม่เช่นนั้นเราต้องระบุวิธีทำลายให้ smart pointer รู้ (ไม่เช่นนั้นมันทำไม่เป็น) ซึ่งผมจะไม่เขียนถึงตอนนี้นะครับ

Dynamic Array

ใน C++ การใช้ new[] เราจะเรียกว่าเป็นการสร้าง dynamic array เพราะว่าเราสามารถใช้สร้าง array ที่เราไม่ทราบขนาดในตอนเขียนโค๊ดได้ เช่น

int* pArray = new int[10];

ข่าวร้ายคือ ตอนนี้เรายังไม่มีวิธีอื่นในการสร้าง dynamic array (นอกเหนือไปจากอีกวิธีนึงของภาษา C ซึ่งไม่ควรใช้พอกัน) ดังนั้นก็จะมีกรณีที่จำเป็นจริง ๆ ก็ใช้ new[] ไปพลาง ๆ ก่อน

แต่กรณีที่จำเป็นต้องใช้ dynamic array จริง ๆ นั้นก็เห็นจะมีแต่การเรียกใช้คำสั่งในภาษา C (ที่ถูก #include เข้ามา) ในกรณีส่วนใหญ่เราสามารถใช้ std::vector แทนได้ ซึ่ง std::vector นั้นมีความสามารถเหนือกว่า array นิดหน่อยตรงที่มันสามารถขยายขนาดของตัวมันเองได้ ในขณะที่ตัวมันเองโดยโครงสร้างแล้วเป็น array list ดังนั้น performance ก็ไม่น่าจะต่ำกว่า array จริง ๆ มากมายเท่าไหร่

อย่างโค๊ดข้างบนเราก็สามารถใช้ std::vector แทนได้ แบบนี้

#include<vector>

std::vector<int> intVec(10);

intVec[0] = 10;

อะเไรก็ว่ากันไป