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