Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Macros

เราได้ใช้แมโคร (macros) อย่าง println! มาตลอดทั้งหนังสือเล่มนี้ แต่เรายังไม่ได้สำรวจอย่างเต็มที่ว่าแมโครคืออะไรและทำงานอย่างไร คำว่า แมโคร (macro) หมายถึงตระกูลของคุณสมบัติใน Rust ซึ่งประกอบด้วยแมโครแบบแจ้งประกาศ (declarative macros) ด้วย macro_rules! และแมโครเชิงขั้นตอน (procedural macros) อีกสามชนิด:

  • แมโคร #[derive] กำหนดเอง (custom #[derive] macros) ที่ระบุโค้ดที่ถูกเพิ่มเข้ามาด้วยแอททริบิวต์ derive ที่ใช้บนสตรักต์และอีนัม
  • แมโครคล้ายแอททริบิวต์ (attribute-like macros) ที่นิยามแอททริบิวต์กำหนดเองเพื่อใช้งานบนไอเทมใดๆ ก็ได้
  • แมโครคล้ายฟังก์ชัน (function-like macros) ที่ดูเหมือนการเรียกใช้ฟังก์ชัน แต่ปฏิบัติการบนโทเค็น (tokens) ที่ถูกระบุเป็นอาร์กิวเมนต์ของมัน

เราจะพูดถึงแต่ละชนิดตามลำดับ แต่ก่อนอื่น เรามาดูกันว่าทำไมเราจึงจำเป็นต้องมีแมโคร ทั้งๆ ที่เรามีฟังก์ชันอยู่แล้ว

The Difference Between Macros and Functions

โดยพื้นฐานแล้ว แมโครคือวิธีเขียนโค้ดเพื่อสร้างโค้ดอื่น ซึ่งเป็นที่รู้จักกันในชื่อ การเขียนโปรแกรมเชิงอภิพรรณนา (metaprogramming) ในภาคผนวก C เราพูดถึงแอททริบิวต์ derive ซึ่งสร้างการอิมพลีเมนต์เทรตต่างๆ ให้คุณโดยอัตโนมัติ เรายังได้ใช้แมโคร println! และ vec! มาตลอดทั้งเล่ม แมโครเหล่านี้ทั้งหมดจะ ขยายผล (expand) เพื่อสร้างโค้ดมากกว่าโค้ดที่คุณเขียนขึ้นเองด้วยตนเอง

การเขียนโปรแกรมเชิงอภิพรรณนามีประโยชน์ในการลดปริมาณโค้ดที่คุณต้องเขียนและดูแลรักษา ซึ่งก็เป็นหนึ่งในบทบาทของฟังก์ชันเช่นกัน อย่างไรก็ตาม แมโครมีพลังเพิ่มเติมบางอย่างที่ฟังก์ชันไม่มี

ลายเซ็นฟังก์ชัน (function signature) ต้องประกาศจำนวนและประเภทของพารามิเตอร์ที่ฟังก์ชันมี ในทางกลับกัน แมโครสามารถรับจำนวนพารามิเตอร์ที่ไม่คงที่ได้: เราสามารถเรียก println!("hello") ด้วยอาร์กิวเมนต์เดียว หรือ println!("hello {}", name) ด้วยสองอาร์กิวเมนต์ได้ นอกจากนี้ แมโครยังถูกขยายผลก่อนที่คอมไพเลอร์จะตีความความหมายของโค้ด ดังนั้นแมโครจึงสามารถทำสิ่งต่างๆ เช่น อิมพลีเมนต์เทรตให้กับประเภทที่กำหนดได้ แต่ฟังก์ชันทำไม่ได้ เนื่องจากฟังก์ชันถูกเรียกใช้ในขณะรันไทม์ และเทรตจำเป็นต้องถูกอิมพลีเมนต์ในขณะคอมไพล์

ข้อเสียของการอิมพลีเมนต์แมโครแทนฟังก์ชันคือ นิยามของแมโครจะมีความซับซ้อนมากกว่านิยามฟังก์ชัน เนื่องจากคุณกำลังเขียนโค้ด Rust ที่สร้างโค้ด Rust ด้วยเหตุของการส่งผ่านทางอ้อมนี้ นิยามแมโครโดยทั่วไปจึงอ่าน เข้าใจ และดูแลรักษาได้ยากกว่านิยามฟังก์ชัน

ความแตกต่างที่สำคัญอีกประการหนึ่งระหว่างแมโครและฟังก์ชันคือ คุณต้องนิยามแมโครหรือนำเข้าสู่ขอบเขต (scope) ก่อน ที่จะเรียกใช้พวกมันในไฟล์ ซึ่งตรงข้ามกับฟังก์ชันที่คุณสามารถนิยามไว้ที่ใดก็ได้และเรียกใช้จากที่ใดก็ได้

Declarative Macros for General Metaprogramming

รูปแบบของแมโครที่ใช้กันแพร่หลายที่สุดใน Rust คือ แมโครแบบแจ้งประกาศ (declarative macro) ซึ่งบางครั้งก็ถูกเรียกว่า “macros by example”, “macro_rules! macros” หรือเรียกสั้นๆ ว่า “แมโคร” โดยหัวใจหลักแล้ว แมโครแบบแจ้งประกาศช่วยให้คุณเขียนบางสิ่งที่คล้ายกับนิพจน์ match ใน Rust ได้ ดังที่ได้หารือกันในบทที่ 6 นิพจน์ match คือโครงสร้างควบคุมที่รับนิพจน์มา เปรียบเทียบค่าผลลัพธ์ของนิพจน์กับรูปแบบ (patterns) แล้วรันโค้ดที่สัมพันธ์กับรูปแบบที่ตรงกัน แมโครก็ทำการเปรียบเทียบค่ากับรูปแบบที่สัมพันธ์กับโค้ดเฉพาะเช่นกัน: ในสถานการณ์นี้ ค่าคือโค้ดต้นฉบับ Rust ตัวอักษรที่ถูกส่งไปยังแมโคร รูปแบบจะถูกเปรียบเทียบกับโครงสร้างของโค้ดต้นฉบับนั้น และโค้ดที่สัมพันธ์กับแต่ละรูปแบบเมื่อตรงกัน จะเข้าแทนที่โค้ดที่ส่งไปยังแมโคร ทั้งหมดนี้เกิดขึ้นในระหว่างการคอมไพล์

ในการนิยามแมโคร คุณจะใช้โครงสร้าง macro_rules! เรามาสำรวจวิธีใช้ macro_rules! โดยดูวิธีที่แมโคร vec! ถูกนิยามไว้ บทที่ 8 ได้ครอบคลุมวิธีที่เราสามารถใช้แมโคร vec! เพื่อสร้างเวกเตอร์ใหม่ที่มีค่าเฉพาะได้ ตัวอย่างเช่น แมโครต่อไปนี้สร้างเวกเตอร์ใหม่ที่บรรจุจำนวนเต็มสามตัว:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

เรายังสามารถใช้แมโคร vec! เพื่อสร้างเวกเตอร์ของจำนวนเต็มสองตัว หรือเวกเตอร์ของ string slices ห้าตัวได้ด้วย เราไม่สามารถใช้ฟังก์ชันทำสิ่งเดียวกันนี้ได้ เพราะเราไม่ทราบจำนวนหรือประเภทของค่าล่วงหน้า

โค้ดตัวอย่างที่ 20-35 แสดงนิยามแบบย่อของแมโคร vec!

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

หมายเหตุ: นิยามที่แท้จริงของแมโคร vec! ในไลบรารีมาตรฐานนั้นรวมถึงโค้ดสำหรับจัดสรรหน่วยความจำขนาดที่ถูกต้องล่วงหน้าด้วย โค้ดนั้นเป็นการเพิ่มประสิทธิภาพการทำงานที่เราไม่ได้นำมารวมไว้ที่นี่ เพื่อให้ตัวอย่างง่ายขึ้น

คำอธิบาย #[macro_export] บ่งบอกว่าแมโครนี้ควรจะเปิดให้ใช้งานได้เมื่อใดก็ตามที่เครตที่นิยามแมโครนี้ถูกนำเข้าสู่ขอบเขต หากไม่มีคำอธิบายนี้ แมโครจะไม่สามารถถูกนำเข้าสู่ขอบเขตได้

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

โครงสร้างในบอดี้ของ vec! คล้ายกับโครงสร้างของนิพจน์ match ในที่นี้เรามีแขนข้างหนึ่งที่มีรูปแบบ ( $( $x:expr ),* ) ตามด้วย => และบล็อกโค้ดที่สัมพันธ์กับรูปแบบนี้ หากรูปแบบตรงกัน บล็อกโค้ดที่สัมพันธ์กันจะถูกปล่อยออกมา (emitted) เนื่องจากนี่เป็นรูปแบบเดียวในแมโครนี้ จึงมีวิธีจับคู่ที่ถูกต้องเพียงวิธีเดียวเท่านั้น รูปแบบอื่นใดจะส่งผลให้เกิดข้อผิดพลาด แมโครที่ซับซ้อนกว่าจะมีมากกว่าหนึ่งแขน

ไวยากรณ์รูปแบบที่ถูกต้องในนิยามแมโครจะแตกต่างจากไวยากรณ์รูปแบบที่ครอบคลุมในบทที่ 19 เนื่องจากรูปแบบแมโครจะถูกเปรียบเทียบกับโครงสร้างโค้ด Rust ไม่ใช่เปรียบเทียบกับค่า เรามาไล่ดูว่าชิ้นส่วนรูปแบบในโค้ดตัวอย่างที่ 20-29 หมายถึงอะไร; สำหรับไวยากรณ์รูปแบบแมโครฉบับเต็ม โปรดดู Rust Reference

ประการแรก เราใช้วงเล็บคู่หนึ่งเพื่อครอบคลุมรูปแบบทั้งหมด เราใช้เครื่องหมายดอลลาร์ ($) เพื่อประกาศตัวแปรในระบบแมโครที่จะบรรจุโค้ด Rust ที่ตรงกับรูปแบบ เครื่องหมายดอลลาร์ช่วยให้ชัดเจนว่านี่คือตัวแปรแมโคร ตรงข้ามกับตัวแปร Rust ปกติ ถัดมาคือวงเล็บคู่หนึ่งที่จับค่าที่ตรงกับรูปแบบภายในวงเล็บเพื่อนำไปใช้ในโค้ดทดแทน ภายใน $() คือ $x:expr ซึ่งตรงกับนิพจน์ Rust ใดๆ และตั้งชื่อนิพจน์นั้นว่า $x

เครื่องหมายจุลภาคตามหลัง $() บ่งบอกว่าตัวอักษรเครื่องหมายจุลภาคที่เป็นตัวคั่นจะต้องปรากฏระหว่างแต่ละอินสแตนซ์ของโค้ดที่ตรงกับโค้ดใน $() เครื่องหมาย * ระบุว่ารูปแบบนั้นตรงกับสิ่งที่อยู่ข้างหน้า * จำนวนศูนย์ครั้งหรือมากกว่านั้น

เมื่อเราเรียกใช้แมโครนี้ด้วย vec![1, 2, 3]; รูปแบบ $x จะตรงกันสามครั้งกับสามนิพจน์คือ 1, 2, และ 3

ตอนนี้มาดูรูปแบบในบอดี้ของโค้ดที่สัมพันธ์กับแขนนี้: temp_vec.push() ภายใน $()* จะถูกสร้างขึ้นสำหรับแต่ละส่วนที่ตรงกับ $() ในรูปแบบศูนย์ครั้งหรือมากกว่านั้นขึ้นอยู่กับจำนวนครั้งที่รูปแบบตรงกัน $x ถูกแทนที่ด้วยแต่ละนิพจน์ที่ตรงกัน เมื่อเราเรียกแมโครนี้ด้วย vec![1, 2, 3]; โค้ดที่สร้างขึ้นเพื่อแทนที่การเรียกแมโครนี้จะเป็นดังนี้:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

เราได้นิยามแมโครที่สามารถรับอาร์กิวเมนต์กี่ตัวก็ได้ ของประเภทใดก็ได้ และสามารถสร้างโค้ดเพื่อสร้างเวกเตอร์ที่บรรจุองค์ประกอบที่ระบุไว้ได้

หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีเขียนแมโคร โปรดศึกษาจากเอกสารออนไลน์หรือทรัพยากรอื่นๆ เช่น “The Little Book of Rust Macros” ซึ่งเริ่มโดย Daniel Keep และสานต่อโดย Lukas Wirth

Procedural Macros for Generating Code from Attributes

รูปแบบที่สองของแมโครคือแมโครเชิงขั้นตอน (procedural macro) ซึ่งทำหน้าที่เหมือนฟังก์ชันมากกว่า (และเป็นชนิดหนึ่งของโพรซีเจอร์) แมโครเชิงขั้นตอน จะรับโค้ดบางอย่างเข้ามาเป็นอินพุต ดำเนินการกับโค้ดนั้น และสร้างโค้ดบางอย่างออกไปเป็นเอาต์พุต แทนที่จะเปรียบเทียบกับรูปแบบและทดแทนโค้ดด้วยโค้ดอื่นอย่างที่แมโครแบบแจ้งประกาศทำ แมโครเชิงขั้นตอนมีสามชนิดคือ derive กำหนดเอง, คล้ายแอททริบิวต์, และคล้ายฟังก์ชัน ซึ่งทั้งหมดทำงานในลักษณะที่คล้ายคลึงกัน

เมื่อสร้างแมโครเชิงขั้นตอน นิยามของมันจะต้องอาศัยอยู่ในเครตของตัวเองด้วยประเภทเครตพิเศษ สิ่งนี้มีเหตุผลทางเทคนิคที่ซับซ้อนที่เราหวังว่าจะสามารถขจัดออกไปได้ในอนาคต ในโค้ดตัวอย่างที่ 20-36 เราแสดงวิธีนิยามแมโครเชิงขั้นตอน โดยที่ some_attribute เป็นตัวแทนการใช้แมโครแต่ละชนิด

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

ฟังก์ชันที่นิยามแมโครเชิงขั้นตอนรับ TokenStream เป็นอินพุต และสร้าง TokenStream เป็นเอาต์พุต ประเภท TokenStream ถูกนิยามโดยเครต proc_macro ที่รวมมากับ Rust และเป็นตัวแทนของลำดับของโทเค็น นี่คือหัวใจของแมโคร: โค้ดต้นฉบับที่แมโครกำลังทำงานด้วยจะทำหน้าที่เป็นอินพุต TokenStream และโค้ดที่แมโครสร้างขึ้นคือเอาต์พุต TokenStream ฟังก์ชันยังมีแอททริบิวต์กำกับไว้ซึ่งระบุชนิดของแมโครเชิงขั้นตอนที่เรากำลังสร้าง เราสามารถมีแมโครเชิงขั้นตอนได้หลายชนิดในเครตเดียวกัน

เรามาดูแมโครเชิงขั้นตอนชนิดต่างๆ กัน เราจะเริ่มด้วยแมโคร derive กำหนดเอง แล้วอธิบายความแตกต่างเล็กๆ น้อยๆ ที่ทำให้รูปแบบอื่นๆ แตกต่างออกไป

Custom derive Macros

เรามาสร้างเครตชื่อ hello_macro ที่นิยามเทรตชื่อ HelloMacro พร้อมฟังก์ชันที่เชื่อมโยงอยู่หนึ่งฟังก์ชันชื่อ hello_macro แทนที่จะให้ผู้ใช้ของเราอิมพลีเมนต์ HelloMacro trait สำหรับแต่ละประเภทของพวกเขา เราจะจัดเตรียมแมโครเชิงขั้นตอนไว้เพื่อให้ผู้ใช้สามารถเขียนคำอธิบายประเภทของตนเองด้วย #[derive(HelloMacro)] เพื่อรับการอิมพลีเมนต์เริ่มต้นของฟังก์ชัน hello_macro การอิมพลีเมนต์เริ่มต้นจะพิมพ์ Hello, Macro! My name is TypeName! โดยที่ TypeName คือชื่อของประเภทที่เทรตนี้ถูกนิยามไว้ กล่าวอีกนัยหนึ่ง เราจะเขียนเครตที่ช่วยให้โปรแกรมเมอร์คนอื่นสามารถเขียนโค้ดดังเช่นโค้ดตัวอย่างที่ 20-37 โดยใช้เครตของเราได้

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

โค้ดนี้จะพิมพ์ Hello, Macro! My name is Pancakes! เมื่อเราทำเสร็จสิ้น ขั้นตอนแรกคือสร้างไลบรารีเครตใหม่ ดังนี้:

$ cargo new hello_macro --lib

ถัดไป ในโค้ดตัวอย่างที่ 20-38 เราจะนิยาม HelloMacro trait และฟังก์ชันที่เชื่อมโยงของมัน

pub trait HelloMacro {
    fn hello_macro();
}

เรามีเทรตและฟังก์ชันของมันแล้ว ถึงจุดนี้ ผู้ใช้เครตของเราสามารถอิมพลีเมนต์เทรตเพื่อบรรลุฟังก์ชันการทำงานที่ต้องการได้ ดังแสดงในโค้ดตัวอย่างที่ 20-39

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

อย่างไรก็ตาม พวกเขาจำเป็นต้องเขียนบล็อกการอิมพลีเมนต์สำหรับแต่ละประเภทที่ต้องการใช้กับ hello_macro; เราต้องการช่วยให้พวกเขาไม่ต้องทำภาระงานนี้

นอกจากนี้ เรายังไม่สามารถจัดเตรียมฟังก์ชัน hello_macro พร้อมการอิมพลีเมนต์เริ่มต้นที่จะพิมพ์ชื่อของประเภทที่เทรตอิมพลีเมนต์อยู่ได้: เนื่องจาก Rust ไม่มีตระหนักรู้โครงสร้างตนเอง (reflection) ดังนั้นมันจึงไม่สามารถค้นหาชื่อประเภทในขณะรันไทม์ได้ เราจึงต้องใช้แมโครเพื่อสร้างโค้ดในขณะคอมไพล์

ขั้นตอนถัดไปคือการนิยามแมโครเชิงขั้นตอน ณ ขณะที่เขียนนี้ แมโครเชิงขั้นตอนจำเป็นต้องอยู่ในเครตของมันเอง ในที่สุดข้อจำกัดนี้อาจถูกยกเลิกไป ข้อตกลงในการจัดโครงสร้างเครตและแมโครเครตมีดังนี้: สำหรับเครตชื่อ foo เครตแมโครเชิงขั้นตอน derive กำหนดเองจะเรียกว่า foo_derive เรามาริมเริ่มเครตใหม่ชื่อ hello_macro_derive ภายในโปรเจกต์ hello_macro ของเรากัน:

$ cargo new hello_macro_derive --lib

สองเครตของเรามีความเกี่ยวข้องกันอย่างใกล้ชิด ดังนั้นเราจึงสร้างเครตแมโครเชิงขั้นตอนไว้ภายในไดเรกทอรีของเครต hello_macro ของเรา หากเราเปลี่ยนนิยามเทรตใน hello_macro เราก็จะต้องเปลี่ยนการอิมพลีเมนต์ของแมโครเชิงขั้นตอนใน hello_macro_derive ด้วย ทั้งสองเครตจำเป็นต้องถูกเผยแพร่แยกกัน และโปรแกรมเมอร์ที่ใช้เครตเหล่านี้จะต้องเพิ่มทั้งสองเป็น dependencies และนำทั้งคู่เข้าสู่ขอบเขต เราสามารถให้เครต hello_macro ใช้ hello_macro_derive เป็น dependency และส่งออกโค้ดแมโครเชิงขั้นตอนซ้ำ (re-export) แทนได้ อย่างไรก็ตาม วิธีที่เราจัดโครงสร้างโปรเจกต์ช่วยให้โปรแกรมเมอร์สามารถใช้ hello_macro ได้ แม้ว่าพวกเขาจะไม่ต้องการฟังก์ชันการทำงานของ derive ก็ตาม

เราจำเป็นต้องประกาศเครต hello_macro_derive เป็นเครตแมโครเชิงขั้นตอน เรายังต้องการฟังก์ชันการทำงานจากเครต syn และ quote ดังที่คุณจะได้เห็นในอีกสักครู่ ดังนั้นเราจึงต้องเพิ่มพวกมันเป็น dependencies เพิ่มเติมดังต่อไปนี้ลงในไฟล์ Cargo.toml ของ hello_macro_derive:

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

ในการเริ่มนิยามแมโครเชิงขั้นตอน ให้ใส่โค้ดในโค้ดตัวอย่างที่ 20-40 ลงในไฟล์ src/lib.rs ของคุณสำหรับเครต hello_macro_derive โปรดทราบว่าโค้ดนี้จะยังไม่คอมไพล์จนกว่าเราจะเพิ่มนิยามสำหรับฟังก์ชัน impl_hello_macro

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}

สังเกตว่าเราได้แยกโค้ดออกเป็นฟังก์ชัน hello_macro_derive ซึ่งมีหน้าที่ในการพาร์ส (parse) TokenStream และฟังก์ชัน impl_hello_macro ซึ่งมีหน้าที่แปลงโครงสร้างต้นไม้ไวยากรณ์ (syntax tree): สิ่งนี้ทำให้การเขียนแมโครเชิงขั้นตอนสะดวกยิ่งขึ้น โค้ดในฟังก์ชันภายนอก (hello_macro_derive ในกรณีนี้) จะเหมือนกันสำหรับเครตแมโครเชิงขั้นตอนเกือบทุกอันที่คุณเห็นหรือสร้างขึ้น โค้ดที่คุณระบุในบอดี้ของฟังก์ชันภายใน (impl_hello_macro ในกรณีนี้) จะแตกต่างกันไปตามวัตถุประสงค์ของแมโครเชิงขั้นตอนของคุณ

เราได้แนะนำเครตใหม่สามอัน: proc_macro, syn, และ quote เครต proc_macro ติดมากับ Rust อยู่แล้ว ดังนั้นเราจึงไม่ต้องเพิ่มลงใน dependencies ใน Cargo.toml เครต proc_macro คือ API ของคอมไพเลอร์ที่ช่วยให้เราอ่านและจัดการโค้ด Rust จากโค้ดของเราได้

เครต syn พาร์สโค้ด Rust จากสตริงให้เป็นโครงสร้างข้อมูลที่เราสามารถดำเนินการสิ่งต่างๆ บนนั้นได้ เครต quote แปลงโครงสร้างข้อมูลของ syn กลับเป็นโค้ด Rust เครตเหล่านี้ช่วยให้พาร์สโค้ด Rust ชนิดใดๆ ที่เราต้องการจัดการได้ง่ายขึ้นมาก: การเขียนตัวพาร์สฉบับเต็มสำหรับโค้ด Rust นั้นไม่ใช่งานง่ายๆ เลย

ฟังก์ชัน hello_macro_derive จะถูกเรียกเมื่อผู้ใช้ไลบรารีของเราระบุ #[derive(HelloMacro)] บนประเภทข้อมูล สิ่งนี้เป็นไปได้เพราะเราได้กำกับฟังก์ชัน hello_macro_derive ที่นี่ด้วย proc_macro_derive และระบุชื่อ HelloMacro ซึ่งตรงกับชื่อเทรตของเรา; นี่คือข้อตกลงที่แมโครเชิงขั้นตอนส่วนใหญ่ปฏิบัติตาม

ฟังก์ชัน hello_macro_derive จะแปลง input จาก TokenStream ไปเป็นโครงสร้างข้อมูลที่เราสามารถตีความและดำเนินการสิ่งต่างๆ บนนั้นได้ก่อน นี่คือจุดที่ syn เข้ามามีบทบาท ฟังก์ชัน parse ใน syn จะรับ TokenStream และคืนค่าเป็นสตรักต์ DeriveInput ที่เป็นตัวแทนของโค้ด Rust ที่พาร์สแล้ว โค้ดตัวอย่างที่ 20-41 แสดงส่วนที่เกี่ยวข้องของสตรักต์ DeriveInput ที่เราได้จากการพาร์สสตริง struct Pancakes;

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

ฟิลด์ของสตรักต์นี้แสดงให้เห็นว่าโค้ด Rust ที่เราพาร์สแล้วเป็นยูหนิตสตรักต์ (unit struct) ที่มี ident (identifier หมายถึงชื่อ) เป็น Pancakes มีฟิลด์เพิ่มเติมบนสตรักต์นี้สำหรับอธิบายโค้ด Rust ชนิดต่างๆ; ตรวจสอบ syn documentation สำหรับ DeriveInput สำหรับข้อมูลเพิ่มเติม

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

คุณอาจสังเกตเห็นว่าเราเรียกใช้ unwrap เพื่อทำให้ฟังก์ชัน hello_macro_derive เกิด panic หากการเรียกใช้ฟังก์ชัน syn::parse ล้มเหลวที่นี่ มันจำเป็นอย่างยิ่งที่แมโครเชิงขั้นตอนของเราจะต้อง panic เมื่อเกิดข้อผิดพลาด เนื่องจากฟังก์ชัน proc_macro_derive ต้องส่งคืน TokenStream แทนที่จะเป็น Result เพื่อให้เป็นไปตาม API ของแมโครเชิงขั้นตอน เราได้ทำให้ตัวอย่างนี้ง่ายขึ้นโดยใช้ unwrap; ในโค้ดสำหรับการผลิตจริง คุณควรให้ข้อความแสดงข้อผิดพลาดเฉพาะเจาะจงมากขึ้นเกี่ยวกับสิ่งที่ผิดพลาดโดยใช้ panic! หรือ expect

ตอนนี้เรามีโค้ดที่จะแปลงโค้ด Rust ที่มีคำอธิบายกำกับจาก TokenStream ให้เป็นอินสแตนซ์ DeriveInput แล้ว เรามาสร้างโค้ดที่อิมพลีเมนต์ HelloMacro trait บนประเภทที่มีคำอธิบายกำกับกัน ดังแสดงในโค้ดตัวอย่างที่ 20-42

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

เราได้อินสแตนซ์สตรักต์ Ident ที่บรรจุชื่อ (identifier) ของประเภทที่มีคำอธิบายกำกับโดยใช้ ast.ident สตรักต์ในโค้ดตัวอย่างที่ 20-41 แสดงว่าเมื่อเรารันฟังก์ชัน impl_hello_macro บนโค้ดในโค้ดตัวอย่างที่ 20-37 ident ที่เราได้จะมีฟิลด์ ident ที่มีค่าเป็น "Pancakes" ดังนี้ ตัวแปร name ในโค้ดตัวอย่างที่ 20-42 จะบรรจุอินสแตนซ์สตรักต์ Ident ซึ่งเมื่อพิมพ์ออกมาแล้ว จะเป็นสตริง "Pancakes" ซึ่งเป็นชื่อของสตรักต์ในโค้ดตัวอย่างที่ 20-37

แมโคร quote! ช่วยให้เรานิยามโค้ด Rust ที่เราต้องการส่งคืนได้ คอมไพเลอร์คาดหวังบางอย่างที่แตกต่างจากผลลัพธ์โดยตรงของการรันแมโคร quote! ดังนั้นเราจึงจำเป็นต้องแปลงมันให้เป็น TokenStream เราทำสิ่งนี้โดยการเรียกใช้เมธอด into ซึ่งจะบริโภคโครงสร้างตัวแทนระดับกลางนี้และคืนค่าเป็นประเภท TokenStream ตามที่ต้องการ

แมโคร quote! ยังมอบกลไกการสร้างแม่แบบ (templating mechanics) ที่เจ๋งมากอีกด้วย: เราสามารถใส่ #name และ quote! จะแทนที่ด้วยค่าในตัวแปร name คุณยังสามารถทำการซ้ำคล้ายกับวิธีที่แมโครธรรมดาทำงานได้ ลองดู เอกสารของเครต quote สำหรับบทนำฉบับสมบูรณ์

เราต้องการให้แมโครเชิงขั้นตอนของเราสร้างการอิมพลีเมนต์ของ HelloMacro trait สำหรับประเภทที่ผู้ใช้ใส่คำอธิบายกำกับไว้ ซึ่งเราสามารถรับมาได้โดยใช้ #name การอิมพลีเมนต์เทรตจะมีหนึ่งฟังก์ชันคือ hello_macro ซึ่งบอดี้ของมันบรรจุฟังก์ชันการทำงานที่เราต้องการจัดเตรียมไว้: นั่นคือพิมพ์ Hello, Macro! My name is ตามด้วยชื่อของประเภทที่มีคำอธิบายกำกับ

แมโคร stringify! ที่ใช้ในที่นี้ถูกสร้างไว้ในตัว Rust มันรับนิพจน์ Rust เช่น 1 + 2 และแปลงนิพจน์นั้นในขณะคอมไพล์ให้เป็นตัวอักษรสตริง (string literal) เช่น "1 + 2" สิ่งนี้แตกต่างจาก format! หรือ println! ซึ่งเป็นแมโครที่ประเมินผลลัพธ์ของนิพจน์แล้วค่อยแปลงผลลัพธ์นั้นเป็น String มีความเป็นไปได้ว่าอินพุต #name อาจเป็นนิพจน์ที่ต้องพิมพ์ตามตัวอักษร เราจึงใช้ stringify! การใช้ stringify! ยังช่วยประหยัดการจัดสรรหน่วยความจำโดยแปลง #name ให้เป็นตัวอักษรสตริงในขณะคอมไพล์

ถึงจุดนี้ cargo build ควรจะคอมไพล์ผ่านสำเร็จทั้งใน hello_macro และ hello_macro_derive เรามาเชื่อมต่อเครตเหล่านี้เข้ากับโค้ดในโค้ดตัวอย่างที่ 20-37 เพื่อดูแมโครเชิงขั้นตอนทำงานกัน! สร้างโปรเจกต์ไบนารีใหม่ในไดเรกทอรี projects ของคุณโดยใช้ cargo new pancakes เราจำเป็นต้องเพิ่ม hello_macro และ hello_macro_derive เป็น dependencies ในไฟล์ Cargo.toml ของเครต pancakes หากคุณกำลังเผยแพร่เวอร์ชันของ hello_macro และ hello_macro_derive ลงใน crates.io พวกมันก็จะเป็น dependencies ปกติ; หากไม่เป็นเช่นนั้น คุณสามารถระบุพวกมันเป็น path dependencies ได้ดังต่อไปนี้:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

ใส่โค้ดในโค้ดตัวอย่างที่ 20-37 ลงใน src/main.rs และรัน cargo run: มันควรจะพิมพ์ Hello, Macro! My name is Pancakes! การอิมพลีเมนต์ HelloMacro trait จากแมโครเชิงขั้นตอนถูกรวมไว้โดยที่เครต pancakes ไม่จำเป็นต้องอิมพลีเมนต์เอง; #[derive(HelloMacro)] ได้เพิ่มการอิมพลีเมนต์เทรตให้แล้ว

ถัดไป เรามาสำรวจว่าแมโครเชิงขั้นตอนชนิดอื่นๆ แตกต่างจากแมโคร derive กำหนดเองอย่างไร

Attribute-Like Macros

แมโครคล้ายแอททริบิวต์ (attribute-like macros) จะคล้ายกับแมโคร derive กำหนดเอง แต่แทนที่จะสร้างโค้ดสำหรับแอททริบิวต์ derive พวกมันอนุญาตให้คุณสร้างแอททริบิวต์ใหม่ขึ้นมาได้ พวกมันยังมีความยืดหยุ่นมากกว่าอีกด้วย: derive ทำงานได้เฉพาะกับสตรักต์และอีนัมเท่านั้น; แต่แอททริบิวต์สามารถนำไปใช้กับไอเทมอื่นได้ด้วย เช่น ฟังก์ชัน นี่คือตัวอย่างการใช้แมโครคล้ายแอททริบิวต์ สมมติว่าคุณมีแอททริบิวต์ชื่อ route ที่กำกับฟังก์ชันเมื่อใช้เว็บแอปพลิเคชันเฟรมเวิร์ก:

#[route(GET, "/")]
fn index() {

แอททริบิวต์ #[route] นี้จะถูกนิยามโดยเฟรมเวิร์กในฐานะแมโครเชิงขั้นตอน ลายเซ็นของฟังก์ชันนิยามแมโครจะมีหน้าตาเป็นดังนี้:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

ในที่นี้ เรามีพารามิเตอร์สองตัวประเภท TokenStream ตัวแรกสำหรับเนื้อหาของแอททริบิวต์: ส่วน GET, "/" ตัวที่สองคือบอดี้ของไอเทมที่แอททริบิวต์แนบอยู่: ในกรณีนี้คือ fn index() {} และส่วนที่เหลือของบอดี้ฟังก์ชัน

นอกเหนือจากนั้น แมโครคล้ายแอททริบิวต์ก็ทำงานในลักษณะเดียวกับแมโคร derive กำหนดเอง: คุณสร้างเครตที่มีประเภทเครต proc-macro และอิมพลีเมนต์ฟังก์ชันที่สร้างโค้ดที่คุณต้องการ!

Function-Like Macros

แมโครคล้ายฟังก์ชัน (function-like macros) นิยามแมโครที่ดูเหมือนการเรียกใช้ฟังก์ชัน คล้ายกับแมโคร macro_rules! พวกมันมีความยืดหยุ่นมากกว่าฟังก์ชัน; ตัวอย่างเช่น พวกมันสามารถรับจำนวนอาร์กิวเมนต์ที่ไม่แน่นอนได้ อย่างไรก็ตาม แมโคร macro_rules! สามารถนิยามได้โดยใช้ไวยากรณ์คล้าย match ที่เราพูดถึงในหัวข้อ “Declarative Macros for General Metaprogramming” ก่อนหน้านี้เท่านั้น แมโครคล้ายฟังก์ชันรับพารามิเตอร์ TokenStream และนิยามของพวกมันจะจัดการกับ TokenStream นั้นโดยใช้โค้ด Rust เช่นเดียวกับแมโครเชิงขั้นตอนอีกสองชนิด ตัวอย่างของแมโครคล้ายฟังก์ชันคือแมโคร sql! ที่อาจถูกเรียกใช้ดังนี้:

let sql = sql!(SELECT * FROM posts WHERE id=1);

แมโครนี้จะพาร์สคำสั่ง SQL ภายในและตรวจสอบว่าถูกต้องตามไวยากรณ์หรือไม่ ซึ่งเป็นการประมวลผลที่ซับซ้อนกว่าแมโคร macro_rules! มาก แมโคร sql! จะถูกนิยามดังนี้:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

นิยามนี้คล้ายกับลายเซ็นของแมโคร derive กำหนดเอง: เราได้รับโทเค็นที่อยู่ภายในวงเล็บและส่งคืนโค้ดที่เราต้องการสร้าง

Summary

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

ถัดไป เราจะนำทุกสิ่งที่เราหารือกันมาตลอดทั้งเล่มไปปฏิบัติจริง และทำอีกหนึ่งโปรเจกต์กันครับ!