VCPKG ระบบจัดการแพคเกจ จากทีม Visual C++

หลาย ๆ คนที่เคยใช้ Linux เนี่ย ไม่ว่าจะเป็น Debian, Redhat หรือ Arch จะคุ้นเคยกับการใช้ระบบ package management system ในการติดตั้ง software ต่าง ๆ รวมทั้งการติดั้ง library สำหรับใช้ในโปรเจคภาษา C หรือ C++ ด้วย ซึ่งมันติดตั้งได้ง่ายมาก แค่คำสั่งหนึ่งบรรทัดมันก็ติดตั้งได้ละ

ในหลายภาษาเองก็มีการจัดการ package สำหรับ library เป็นของตัวเอง อย่าง go get หรือ npm install เป็นต้น

แต่ ใน C++ นั้นไม่มีวิธีการจัดการในตัวมันเอง ยิ่งถ้าเราใช้ระบบปฎิบัติการอย่าง Windows ที่ก็ไม่มีระบบจัดการแพคเกจ (อย่างเป็นทางการ) เป็นของตัวเอง ก็จะยิ่งเหนื่อยหน่อย เวลาเซ็ตอัพโปรเจคทีก็ต้องมานั่งดาวน์โหลด Library มาเซ็ต path และอื่น ๆ ก็รู้สึกว่าน่าเบื่อใช่ไหมครับ

วันนี้เราขอเสนอ หนึ่งในทางเลือก package management ของ C++ ที่ชื่อว่า VCPKG

VCPKG จาก Microsoft

VCPKG เป็นตัวจัดการแพคเกจจาก Microsoft ที่ทำงานได้ทั้งบน Windows, MacOS และ Linux มันเป็นตัวจัดการ library ที่ทำงานบน command line ทั้งนี้หลังจากติดตั้งเสร็จ เราจะทำให้มันทำงานร่วมกับ Visual Studio ได้เลย โดยตัว package ที่ติดตั้งมาจะถูกพบโดย Visual Studio ทำให้เราไม่ต้องตั้งค่าวุ่นวายอีก

นอกจากนั้น ตัว vcpkg สามารถทำงานร่วมกับ CMAKE ได้อีกด้วย (แต่เดี๋ยวจะพูดถึงตรงนี้อีกทีทีหลังครับ)

การติดตั้ง VCPKG

การติดตั้งนั้นง่ายมาก วิธีติดตั้งทำตามบน github ได้เลย ซึ่งจริง ๆ บน Windows ก็ทำแค่

> git clone https://github.com/Microsoft/vcpkg.git
> cd vcpkg
> .\bootstrap-vcpkg.bat
> .\vcpkg integrate install

แค่นั้นเอง

ติดตั้ง package บน VCPKG

ตัว package ต่าง ๆ สามารถติดตั้งได้ง่าย ๆ ด้วยคำสั่ง vcpkg install <packagename> เช่น vcpkg install sfml เป็นต้น

VCPKG จะทำการดาวน์โหลดซอร์สโค๊ดของ package ทั้งที่เราเป็นคนใส่ และ dependency ทั้งหมดของ package ที่เราเลือก (ถ้ายังไม่มีแพคเกจดังกล่าวในระบบ) จากนั้นมันจะทำการ build package ทั้งหมดในเครื่องเรา และติดตั้งไปใน directory ของมันเอง

การใช้งานใน Visual Studio

อันนี้ง่ายมาก คือ library ที่เราติดตั้งเนี่ยครับ เราสามารถ #include ได้เลย ไม่ต้องแก้อะไรอีก และเมื่อ #include แล้ว VS จะ link library ที่เราใช้ให้อัตโนมัติ

อย่างอันนี้ผมแค่สร้างโปรเจคใหม่ขึ้นมา เพิ่มไฟล์ใหม่เข้าไป แล้วก็สั่ง build ใช้งานได้เลย ง่ายมาก

การใช้งานกับ CMake

สำหรับโปรเจคที่เป็น CMake เนี่ย เราจะสร้างไฟล์ CMakelist.txt โดยนอกจากที่เราจะเพิ่มไฟล์เข้าไปในโปรเจคแล้ว เราก็จะเพิ่มพวกคำสั่ง find_package() กับ target_link_libraries() เข้าไปอีกหน่อยนึง โดยตอนที่เราติดตั้งแพคเกจ vcpkg จะแนะนำว่าให้ใส่อะไรลงไปบ้าง ….

-- Performing post-build validation done
Building package sfml[core]:x64-windows... done
Installing package sfml[core]:x64-windows...
Installing package sfml[core]:x64-windows... done
Elapsed time for package sfml:x64-windows: 35.96 s

Total elapsed time: 3.321 min

The package sfml:x64-windows provides CMake targets:

    find_package(SFML CONFIG REQUIRED)
    # Note: 7 target(s) were omitted.
    target_link_libraries(main PRIVATE FLAC OpenAL OpenGL Vorbis)

อย่างอันนี้ผมติดตั้ง SFML มันก็จะแนะนำมาเลยว่า ต้องใส่ find_package(SFML CONFIG REQUIRED) กับ target_link_libraries(main PRIVATE FLAC OpenAL OpenGL Vorbis) ใน CMakelists.txt นะ

แต่กรณีของ SFML เนี่ยคนที่เขาทำ package เขาเหมือนจะลืมบอกเรื่องของ module ไปครับ เราก็ต้องไปใส่ให้มันครบ (ฮา) ก็จะวุ่นวายหน่อยนึง อย่างไฟล์ CMakelists.txt ของผมจะมีหน้าตาประมาณนี้ ถึงจะบิลด์โปรเจคผ่าน

cmake_minimum_required(VERSION 3.15)

project(testSFML)

add_executable(main "main.cpp")

find_package(SFML CONFIG COMPONENTS system window graphics REQUIRED)
target_link_libraries(main PRIVATE FLAC OpenAL OpenGL Vorbis sfml-graphics)

ถึงจุดนี้ ถ้าสมมติว่าผมใช้ Visual Studio ผมสามารถที่จะเปิดโปรเจคนี้โดยการเลือก File > Open > Folder... แล้ว Visual Studio ก็จะเปิดโปรเจคขึ้นมาด้วยตัว CMake Support ของ VS เอง

หรือถ้าจะไม่ใช้ Visual Studio CMake Support เราก็สามารถใช้ CMake เพื่อสร้างตัว Build File ด้วยวิธีปรกติครับ แต่จะต้องใช้ CMake Toolchain file ของ VCPKG ด้วย option -DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake

ซึ่งถ้าจำไม่ได้ว่าไฟล์อยู่ไหน ให้ลองรัน vcpkg integrate install มันจะแสดงคำสั่งนี้ขึ้นมาให้ครับ

“Triplet”

Triplet เป็นคอนเซพท์ของ VCPKG ที่เหมือน platform ต่าง ๆ ที่ VCPKG รองรับ โดยก็จะมีส่วนของ isa และส่วนของ os เช่น x86-windows หรือ x64-windows ซึ่ง ถ้าโปรเจคที่เราทำงานด้วยนั้นใช้ target ที่ไม่ตรงกับ triplet ที่เราติดตั้ง โปรเจคเราก็จะ build ไม่ผ่านครับ

ทีนี้ ตอนที่เราติดตั้ง package เราสามารถระบุได้เลยว่าจะใช้ triplet ไหน โดยใช้ : คั่นระหว่าง package และ triplet

เช่น vcpkg install sfml:x86-windows

ซึ่งถ้าเราไม่ระบุ มันจะใช้ค่า default โดยค่า default บน Windows ก็จะเป็น x86-windows แต่เราสามารถเพิ่ม environment variable ชื่อว่า VCPKG_DEFAULT_TRIPLET เพื่อเปลี่ยนตัว default เป็น triplet ที่เราต้องการได้เลยครับ

Structure Binding ใครว่า ฟังก์ชันใน C++ คืนค่าได้แค่ค่าเดียว ?

TL;DR มันก็ได้ค่าเดียวแหละ เอาตรง ๆ แค่มันมีฟีเจอร์ที่สามารถแยกค่าออกมาเป็นค่าย่อย ๆ แล้วจับใส่ตัวแปรหลาย ๆ ตัวได้ต่างหาก

ผมว่า คนที่เขียนภาษาอย่าง Go มา คงจะเคยเอาไปข่มคนที่เขียนภาษาอื่น ๆ ประมาณว่า “ภาษาชั้นสามารถคืนค่าจากฟังก์ชันได้หลายๆ ค่าพร้อมกันเฟร้ย ภาษาแกทำได้มั้ยเล่า”

แบบ

var result, err = Process(input)

ซึ่งในภาษา C++ เราคงเขียนแบบประมาณว่า ….

auto err = Process(&result);

ซึ่งมันไม่เท่ห์เอาซะเลย (ฮา)

… แต่เดี๋ยวก่อน ถ้าผมจะบอกว่าเราเขียนในรูปเดียวกันได้ในภาษา C++ ล่ะ ?? คุณจะเชื่อมั้ย ? …

ในมาตรฐาน C++17 มีฟีเจอร์ใหม่ที่มีชื่อว่า Structure Binding ซึ่งทำให้เราสามารถเขียนโค๊ดในรูปนี้ได้

auto [result, error] = Process(input);

สนใจหรือยังครับ 😀

รายละเอียดอยู่ที่การ declare function

ฟังก์ชัน Process นี่ ดูจากการเรียกฟังก์ชันแล้วน่าจะเป็นการคืนหลาย ๆ ค่า ใช่ไหมครับ ? ถ้าดูเผิน ๆ ก็เป็นแบบนั้นครับ แต่ฟังก์ชันนี้มีหน้าตาประมาณนี้ต่างหาก

struct Outcome {
    Result result,
    Error error
}

Outcome Process(const Input& input);

ครับ มันก็คืนค่าเดียวนี่แหละ ง่าย ๆ แต่ตอนรับค่าเนี่ย แทนที่เราจะเขียนแบบ …

auto outcome = Process(input);
auto result = outcome.result;
auto err = outcome.error;

เราก็จับย่อมันเหลือบรรทัดเดียวซะเลย เป็น auto [result, err] = ... แทน ซึ่งนอกจากจะประหยัดตัวแปรไปได้หนึ่งตัวแล้ว ก็ไม่ต้องมานั่งเขียนอีกสองบรรทัดอีกต่างหาก

โดยหลัก ๆ ของการ structure binding คือ เรา bind ตัวแปรหนึ่งตัว เข้ากับสมาชิกของ structure หนึ่งตัว ตามลำดับ เท่านั้นเอง

แต่นั่นหมายถึงว่า เราต้องสร้าง struct ใหม่ทุกครั้งที่เขียนฟังก์ชัน ??

แน่นอนว่าถ้าเราต้องมานั่งสร้าง struct ใหม่สำหรับทุก ๆ ฟังก์ชัน แบบเอา result ไปผูกกับ error เป็นอีก type นึง ผมว่ามันก็คงน่ารำคาญน่าดู โชคดีว่าเราไม่ต้องทำแบบนั้นครับ เพราะว่าใน standard library มี std::pair ให้เราใช้ได้ง่าย ๆ

ไอ้ฟังก์ชัน Process() ข้างบน ก็ย่อลงมาเหลือแค่นี้ได้ครับ

std::pair<Result, Error> Process(const Input& input);

จากนั้นก็ใช้ได้เหมือนเดิม

แล้วถ้าคืนค่ามากกว่าสองตัวล่ะ ??

ผมว่าถ้าเห็นชื่อ type แล้ว คงเข้าใจได้ว่า มันเก็บค่าได้แค่สองค่าเท่านั้น ซึ่งอันนี้ถูกต้องแล้วครับ std::pair เป็น struct ที่มีสมาชิกเป็นตัวแปรสองตัว ดังนั้นจะเก็บค่ามากกว่านั้นก็คงไม่ได้ครับ

แต่ใน C++ เองก็ยังมีอีก type นึงให้เราใช้ นั่นคือ std::tuple นั่นเอง วิธีใช้ไม่ต่างกันเลยครับ อย่างเช่น …

std::tuple<float, float, float, Error> GetPosition();

auto [x, y, z, err] = GetPosition();

อะไรแบบนี้

std::pair กับ std::tuple นั้นมีความแตกต่างในรายละเอียดพอสมควร อย่างการเข้าถึงสมาชิกของ std::pair นั้นสามารถทำได้ง่าย ๆ ผ่านสมาชิกที่ชื่อ first และ second แต่ของ std::tuple จะต้องใช้ฟังก์ชันน get() ซึ่งวุ่นวายกว่าพอสมควร แต่ถ้าเรา structure binding แล้ว ทั้งสองตัวก็สามารถเรียกใช้ได้ไม่แตกต่างกันมากครับ

อ้อ std::pair นั้นดูจะเป็นมิตรกับตัว optimizer มากกว่าครับ เพราะความเรียบง่ายของตัว type เองนั่นแหละ

Structure Binding ทำอะไรได้อีก

จริง ๆ ฟีเจอร์ structure binding ก็ตามชื่อ คือการสร้าง binding เข้าหาสมาชิกแต่ละตัวใน struct ซึ่ง ที่ผมพูดมาทั้งหมดเป็นการสร้างตัวแปรที่รับค่าจาก structure แต่อีกอย่างที่ทำได้คือการสร้าง reference จำนวนนึงที่ refer ไปหาสมาชิก แทนที่จะเป็นการสร้าง variable แล้วก็อปปี้ค่าใส่ครับ

Position pos{};
auto &[x,y,z] = pos;

คราวนี้ แทนที่ x, y, z จะมีค่าเป็นของตัวเอง ก็กลายเป็นแค่ reference ที่ refer ไปหาสมาชิกของ pos อีกทีแทนครับ

const parameter reference … ทำไมคอมไพล์ไม่ผ่านฟะ ? กับอีกเรื่องพื้น ๆ ที่ลืมได้ตลอด

ช่วงนี้กลับมาเขียน C++ เล่น ๆ อีกครับ แล้วก็แบบ เขียนตามความเคยชิน เขียน ๆ ไปเรื่อย ๆ แล้วก็ … เฮ่ย ทำไมมันคอมไพล์ไม่ผ่านล่ะเนี่ย??

ติดอยู่หลายวันเหมือนกัน คือ ก็ทำให้มันคอมไพล์ผ่านได้ แต่ก็เป็นการเปลี่ยนไปใช้ฟีเจอร์อื่นแทน ไม่ได้ใช้แบบที่คุ้นเคย ก็เลยรู้สึกคาใจ นั่งหาอยู่สองวันก็เพิ่งมาถึงบางอ้อ …

อ้อ เวร … แค่นี้นี่เองที่หลงลืม แก่แล้วสิเนี่ย 555

… เอ้า พูดมาก็เยอะ ยังไม่บอกเลยว่าปัญหาคืออะไร แต่ผมว่าหลาย ๆ ท่านเห็นหัวเรื่องก็น่าจะรู้แล้วล่ะ …

และนี่คือโค๊ดเจ้าปัญหาครับ

#include <iostream>

class Device {
public:
    int GetID() {return id;}
    Device(const int& _id){ id = _id;}
private:
    int id;
};

class Window {
public:
    void Setup(const Device& device){
        std::cout<<"Using Device"<<device.GetID();
    }
};

int main(int argc, char** argv) {
    Device d{10};
    Window w;
    w.Setup(d);
}

เขียนแค่นี้หลายคนน่าจะเห็นละว่าปัญหาอยู่ที่ตรงไหน ในขณะที่อีกคนก็ยังงง ๆ ผมจะเริ่มจากจุดที่ดูประหลาดที่สุดสำหรับคนที่มาจากภาษาอื่นละกันนะครับ

void Setup(const Device& device)

ตรงนี้จริง ๆ เราจะเขียนว่า void Setup(Device device) ก็ได้ แต่ไม่เป็นที่นิยม คือไม่ใช่กระแดะต้องเขียนยาว ๆ แต่การเขียนรูปนี้เป็นการป้องกันการก็อปปี้ค่าจากตอนที่ถูกเรียนเข้าไปในฟังก์ชันที่ถูกเรียก เราใช้ reference แทนเพื่อเป็นการอ้างอิงไปถึงค่านั้นโดยตรง และเพื่อเป็นการป้องกันการแก้ไขข้อมูลค่าของ parameter โดยที่ไม่ตั้งใจ (เพราะสิ่งที่เราทำตรงนี้คือการ pass-by reference ซึ่งค่าที่ส่งเข้ามากับค่าที่ใช้ตอนเรียกฟังก์ชันจะเป็นค่าเดียวกัน) เราก็เลยใส่เอาไว้ว่าเป็น const Device& นะ

แต่พอคอมไพล์เราก็จะได้ error ดังนี้

$ clang++ main.cpp 
main.cpp:14:36: error: 'this' argument to member function 'GetID' has type 'const Device', but function is not marked const
        std::cout<<"Using Device"<<device.GetID();
                                   ^~~~~~
main.cpp:5:9: note: 'GetID' declared here
    int GetID() {return 0;}
        ^
1 error generated.

เจ๊งซะงั้น ?

คือถ้าฟังก์ชันนี้รับค่าเป็น integer มันจะใช้ได้ครับ แต่พอรับเป็น const Device& มันจะพัง 🙂

แล้วจะแก้ไงดี ?

คำตอบแรกที่เข้ามาในหัวหลังจากที่มันคอมไพล์ไม่ผ่านคือ … เอา const ออกครับ กลายเป็น void Setup(Device& device) มันก็คอมไพล์ผ่านแหละ แต่การที่เรา pass-by reference แล้วไม่แก้ไขอะไรเลยเนี่ย มันเป็น code smell อย่างนึงแฮะ คือมันดูแปลก ๆ เพราะปรกติเรามักจะใช้ pass-by reference เพื่อแก้ไขค่าในตัว parameter ที่ส่งเข้ามาครับ การเรียกลักษณะนี้เลยดูแปลก ๆ

และที่สำคัญคือ ถ้าเเอาออกแล้ว เราจะเรียกฟังก์ชันนี้กับค่าที่เป็น ค่าคงที่ หรือค่าที่คืนมาจากฟังก์ชันอื่น หรือการสร้าง temporary object ไม่ได้ เพราะมันมีกฎว่า non-const reference จะต้องชี้ไปหาตัวแปรสักตัวนึงเสมอ (ในขณะที่ const reference สามารถเป็น refer หา temporary object ได้ครับ)

 $ clang++ main.cpp 
main.cpp:21:13: error: non-const lvalue reference to type 'Device' cannot bind to a temporary of type 'Device'
    w.Setup(Device(2));
            ^~~~~~~~~
main.cpp:13:25: note: passing argument to parameter 'device' here
    void Setup( Device& device) {
                        ^
1 error generated.

แล้วจะแก้ไงดี … ลองกลับไปดูที่ฟังก์ชัน Setup() อีกทีนะครับ

ตัวค่า device เนี่ย มี type เป็น const Device& ใช่มั้ยครับ นั่นหมายความว่า ตัวแปรตัวนี้เป็นตัวแปรแบบ const ซึ่งความหมายของมันคือ เป็น read-only ดังนั้นคอมไพล์เลอร์จะยอมให้อ่านค่าจากตัวแปรตัวนี้เท่านั้น ไม่สามารถเขียนอะไรลงไปเพื่อเปลี่ยนค่าได้

ซึ่ง ไอ้ ฟังก์ชัน GetID() เนี่ย เราก็เห็นอยู่แล้วว่า มันไม่ได้แก้ไขค่าอะไรในตัว class Device

แต่คอมไพล์เลอร์ของ C++ มันไม่ได้ฉลาดขนาดนั้นครับ มันไม่สามารถที่จะแค่อ่านฟังก์ชันแล้รู้เลยว่า เออฟังก์ชันนี้มันไม่ได้แก้ไขค่าอะไรนะ เราต้องบอกมันเองว่า ฟังก์ชันนี้ไม่ได้แก้ไขค่า

ซึ่งในทาง C++ เราจะบอกมันว่า ฟังก์ชันนี้เป็น constant member function

ซึ่งสามารถทำได้โดยการเติม keyword const เข้าไปท้าย function signature ครับ

ก็จะเป็นราว ๆ นี้

#include <iostream>

class Device {
public:
    int GetID() const {return id;}
    Device(const int& _id){ id = _id;}
private:
    int id;
};

class Window {
public:
    void Setup(const Device& device) {
        std::cout<<"Using Device"<<device.GetID();
    }
};

int main(int argc, char** argv) {
    Device d{10};
    Window w;
    w.Setup(d);
    w.Setup(Device(2));
}

แค่นี้ก็คอมไพล์ผ่าน รันได้เรียบร้อย

คราวนี้ก็เขียนเอาไว้ เผื่อเดี๋ยวลืมอีก แน่นอนว่าเดี๋ยวก็ต้องลืมอีก นี่ไม่ใช่ครั้งแรกที่ผมลืมกฎข้อนี้ครับ 555