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

ประเภทข้อมูลแบบเจเนอริก, ทริต, และอายุการใช้งาน (Generic Types, Traits, and Lifetimes)

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

ฟังก์ชันต่าง ๆ สามารถรับพารามิเตอร์ที่เป็นประเภทข้อมูลเจเนอริกได้ แทนที่จะระบุประเภทรูปธรรมคงที่อย่าง i32 หรือ String ซึ่งจะเปรียบเสมือนกับการที่ฟังก์ชันรับพารามิเตอร์ที่มีค่าไม่แน่นอนเพื่อรันโค้ดชุดเดียวกันกับค่าข้อมูลรูปธรรมที่หลากหลาย ในความเป็นจริง เราได้ใช้งานเจเนอริกกันไปบ้างแล้วในบทที่ 6 กับประเภทข้อมูล Option<T> ในบทที่ 8 กับ Vec<T> และ HashMap<K, V> รวมถึงในบทที่ 9 กับ Result<T, E> ในบทนี้คุณจะได้ศึกษาเรียนรู้วิธีการนิยามประเภทข้อมูล, ฟังก์ชัน, และเมธอดต่าง ๆ ด้วยระบบเจเนอริกของตัวคุณเอง!

เริ่มแรก เราจะทบทวนวิธีการแยกฟังก์ชัน (extracting a function) เพื่อช่วยลดความซ้ำซ้อนของโค้ด จากนั้นเราจะใช้เทคนิคกระบวนการเดียวกันนี้ในการสร้างฟังก์ชันแบบเจเนอริกจากฟังก์ชันสองตัวที่แตกต่างกันเพียงแค่ประเภทข้อมูลของพารามิเตอร์ นอกจากนี้เราจะมาอธิบายวิธีใช้งานประเภทข้อมูลเจเนอริกในส่วนนิยามของ struct และ enum ด้วย

จากนั้น คุณจะได้เรียนรู้วิธีการใช้ทริต (traits) เพื่อกำหนดพฤติกรรม (behavior) ในรูปแบบเจเนอริก คุณสามารถนำทริตมาใช้ผสมผสานร่วมกับเจเนอริกเพื่อจำกัดเงื่อนไข (constraint) ให้เจเนอริกยอมรับเฉพาะประเภทข้อมูลที่มีพฤติกรรมตามที่กำหนดเท่านั้น แทนที่จะยอมรับประเภทข้อมูลใด ๆ ก็ได้ทั่วไป

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

การลดโค้ดซ้ำซ้อนด้วยการแยกฟังก์ชัน (Removing Duplication by Extracting a Function)

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

เราจะเริ่มต้นศึกษาจากโปรแกรมขนาดเล็กในตัวอย่างที่ 10-1 ซึ่งมีหน้าที่ค้นหาตัวเลขที่มีค่ามากที่สุดในรายการข้อมูล

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}

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

ขณะนี้เราได้รับโจทย์ให้เขียนคำสั่งเพื่อค้นหาตัวเลขที่มีค่ามากที่สุดจากรายการข้อมูลสองชุดที่แตกต่างกัน ในการทำเช่นนั้น เราอาจเลือกเขียนโค้ดซ้ำแบบตัวอย่างที่ 10-1 ไปวางไว้ในตำแหน่งการทำงานที่ต่างกันสองจุดในโปรแกรม ดังที่แสดงในตัวอย่างที่ 10-2

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}

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

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

ในตัวอย่างที่ 10-3 เราได้แยกโค้ดส่วนที่ค้นหาตัวเลขที่มีค่ามากที่สุดออกไปเป็นฟังก์ชันชื่อว่า largest จากนั้นเราทำการเรียกใช้ฟังก์ชันดังกล่าวเพื่อหาตัวเลขที่มีค่ามากที่สุดในรายการตัวเลขทั้งสองชุดจากตัวอย่างที่ 10-2 นอกจากนี้เรายังนำฟังก์ชันนี้ไปเรียกใช้กับรายการข้อมูล i32 ใด ๆ ที่อาจเกิดขึ้นในอนาคตได้อย่างไม่มีข้อจำกัด

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}

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

สรุปคือ นี่คือขั้นตอนต่าง ๆ ที่เราได้ปรับเปลี่ยนโครงสร้างโค้ดจากตัวอย่างที่ 10-2 เป็นตัวอย่างที่ 10-3:

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

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

ตัวอย่างเช่น สมมติว่าเรามีฟังก์ชัน 2 ฟังก์ชัน ได้แก่ ฟังก์ชันหนึ่งสำหรับค้นหาข้อมูลที่มีค่ามากที่สุดใน slice ของข้อมูล i32 และอีกฟังก์ชันสำหรับค้นหาข้อมูลที่ใหญ่ที่สุดใน slice ของข้อมูล char เราจะกำจัดโค้ดซ้ำซ้อนคู่นี้ได้อย่างไร? เรามาหาคำตอบกันครับ!