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

สิทธิ์การครอบครองหน่วยความจำคืออะไร? (What Is Ownership?)

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

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

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

ข้อมูลประเภทสแตกและฮีป (The Stack and the Heap)

ภาษาโปรแกรมส่วนใหญ่มักจะไม่ร้องขอให้คุณมาคอยกังวลคิดถึงโครงสร้างหน่วยความจำประเภทสแตก (stack) และฮีป (heap) บ่อยครั้งนัก แต่สำหรับภาษาประเภทสำหรับระบบ (systems programming language) อย่างเช่น Rust การที่มูลค่าตัวแปรจะถูกจัดเก็บไว้บน stack หรือ heap จะส่งผลให้ระบบการทำงานภาษาตอบสนองแตกต่างกันและเป็นเหตุผลสำคัญว่าทำไมคุณต้องตัดสินใจเขียนโค้ดรูปประโยคแบบเฉพาะเจาะจง รายละเอียดบางส่วนของระบบสิทธิ์ครอบครองสิทธิ์จะได้รับการอ้างอิงและบรรยายเชื่อมโยงกับ stack และ heap ในบทนี้ ดังนั้นต่อไปนี้เป็นคู่มือคำอธิบายย่อยเพื่อเตรียมความพร้อมเบื้องต้น

ทั้งสแตก (stack) และฮีป (heap) ต่างเป็นพื้นที่ส่วนหน่วยความจำที่โค้ดของคุณสามารถดึงมาจัดสรรเก็บข้อมูลได้ในขณะรันโปรแกรม (runtime) แต่จะมีวิธีการจัดวางโครงสร้างพื้นที่ที่แตกต่างกันอย่างสิ้นเชิง โดยสแตกจะทำหน้าที่เก็บมูลค่าข้อมูลตามลำดับเวลาที่ได้รับส่งเข้ามา และจะถอดถอนล้างข้อมูลย้อนกลับทิศทางตรงกันข้าม ซึ่งพฤติกรรมนี้จะเรียกว่า เข้าทีหลัง ออกก่อน (last in, first out - LIFO) ให้นึกภาพซ้อนทับเสมือนตั้งจานอาหาร: เมื่อต้องการวางจานเพิ่ม คุณจะวางซ้อนไว้ที่ด้านบนสุดของกองจาน และเมื่อต้องการหยิบจานไปใช้งาน คุณจะดึงเอาจานใบด้านบนสุดออกไปก่อน การพยายามยัดจานหรือดึงจานออกจากส่วนกลางหรือส่วนล่างของตั้งจานจะไม่สะดวกแน่นอน! ขั้นตอนป้อนจัดเก็บข้อมูลลงสแตกจะเรียกว่า การพุชเข้าสแตก (pushing onto the stack) และขั้นตอนดึงข้อมูลออกจะเรียกว่า การป็อปออกจากสแตก (popping off the stack) และที่สำคัญ ข้อมูลชิ้นใดๆ ที่ต้องการจะนำมาจัดเก็บจองไว้บน stack จำเป็นต้อง มีขนาดความจุข้อมูลที่ระบบรับทราบตายตัวแน่นอนตั้งแต่แรกเริ่ม (known, fixed size) ส่วนชิ้นข้อมูลใดๆ ที่ยังประเมินขนาดความกว้างไม่ได้ในช่วงเวลาคอมไพล์ หรือขนาดอาจขยายตัวแปรผันได้ระหว่างรันโปรแกรม จะต้องนำไปบันทึกเก็บไว้ในส่วน heap แทน

ส่วนพื้นที่ฮีป (heap) จะมีความไร้ระเบียบมากกว่า: เมื่อคุณต้องการบันทึกข้อมูลลง heap คุณต้องสั่งส่งคำร้องขอจองขนาดพื้นที่หน่วยความจำที่ต้องการไปให้ระบบช่วยหาตำแหน่งว่างให้ ระบบจัดสรรหน่วยความจำ (memory allocator) จะสแกนค้นหาช่องว่างที่กว้างใหญ่พอแก่ความต้องการใน heap จากนั้นจะทำเครื่องหมายกากบาทว่าตำแหน่งนี้ถูกใช้งานแล้ว และส่งคืนค่าสัญลักณ์ชี้พิกัดตำแหน่ง (pointer) ซึ่งทำหน้าที่เป็นข้อมูลระบุที่อยู่ของตำแหน่งนั้นๆ กลับคืนมาให้ กระบวนการจองหน่วยความจำแบบนี้เรียกว่า การจัดสรรจองหน่วยความจำบนฮีป (allocating on the heap) หรือเรียกสั้นๆ ย่อยว่า การจัดสรรจอง (allocating) (การพุชค่าข้อมูลลง stack จะไม่ถูกนับเป็นการจัดสรรจองลักษณะนี้) และเนื่องจากมูลค่าตัวชี้ pointer ไปยังตำแหน่ง heap จะมีขนาดความกว้างที่ตายตัวแน่นอนคงที่ คุณจึงสามารถสั่งบันทึกตัวชี้ pointer นี้ลงใน stack ได้ปกติ แต่เมื่อใดที่คุณต้องการอ่านเนื้อข้อมูลภายในชิ้นงานจริงๆ คุณต้องใช้วิธีสะกดรอยตามตัวชี้ pointer นั้นไปอ่านที่ heap อีกที ให้นึกภาพเสมือนพฤติกรรมการบริการในร้านอาหาร: เมื่อคุณเดินเข้าร้านอาหาร คุณแจ้งจำนวนสมาชิกในกลุ่ม พนักงานต้อนรับจะโต๊ะว่างที่รองรับจำนวนคนได้เหมาะสมแล้วนำทางคุณไปยังโต๊ะอาหารตัวนั้น และหากเพื่อนของคุณตามมาทีหลัง พวกเขาสามารถสอบถามพนักงานว่าโต๊ะที่คุณนั่งอยู่ตั้งอยู่โซนใดเพื่อเดินตามมาหาคุณได้ถูกต้อง

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

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

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

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

กฎของสิทธิ์การครอบครองหน่วยความจำ (Ownership Rules)

อันดับแรก ลองมาพิจารณากฎเกณฑ์พื้นฐานของระบบสิทธิ์การครอบครองหน่วยความจำด้วยกัน โปรดจดจำกฎเกณฑ์เหล่านี้ให้ขึ้นใจระหว่างที่เราไล่ศึกษาภาพตัวอย่างต่าง ๆ ด้านล่างนี้ประกอบ:

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

ขอบเขตการทำงานของตัวแปร (Variable Scope)

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

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

#![allow(unused)]
fn main() {
let s = "hello";
}

ตัวแปร s โยงเก็บข้อมูลค่าคงที่ข้อความ (string literal) โดยที่มูลค่าข้อความดังกล่าวจะถูกฝังค่าลงไปในซอร์สโค้ดของโปรแกรมโดยตรง ตัวแปรนี้จะสามารถเริ่มเรียกใช้งานได้ตั้งแต่ขั้นตอนบรรทัดที่มันถูกประกาศสร้างไปเรื่อยๆ จนถึงพิกัดสิ้นสุดขอบเขตงานปัจจุบัน รายการที่ 4-1 แสดงตัวอย่างโปรแกรมและคำอธิบายโค้ดระบุพื้นที่ตำแหน่งที่ตัวแปร s จะมีผลทำงาน:

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

กล่าวอีกนัยหนึ่ง มีจุดเวลาที่สำคัญ 2 จุดดังนี้:

  • เมื่อ s เข้ามาอยู่ภายในขอบเขตการทำงาน มันจะมีผลใช้งานได้ทันที
  • มันจะยังคงใช้งานได้ไปเรื่อยๆ จนกระทั่งหลุดออกนอกขอบเขตการทำงานไป

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

ชนิดข้อมูลข้อความประเภท String

เพื่อทำความเข้าใจกฎของสิทธิ์การครอบครองหน่วยความจำ เราจำเป็นต้องศึกษาด้วยประเภทข้อมูลที่มีความซับซ้อนมากกว่าชนิดข้อมูลทั่วไปที่เราพูดคุยกันไว้ในหัวข้อ “ชนิดข้อมูล” ในบทที่ 3 โดยกลุ่มชนิดข้อมูลที่ศึกษาไปก่อนหน้านี้ล้วนมีขนาดความจุที่ระบบรับทราบแน่นอนตั้งแต่ตอนคอมไพล์ จึงสามารถจัดเก็บไว้บน stack และป็อปออกจากหน่วยความจำได้สะดวกเมื่อจบรอบขอบเขต รวมถึงสามารถคัดลอกส่งมอบเป็นมูลค่าอิสระแยกตัวออกไปใช้งานได้อย่างรวดเร็วในกรณีที่โค้ดส่วนอื่นต้องการเรียกใช้ข้อมูลชิ้นเดียวกัน แต่ในหัวข้อนี้เราต้องการจะสำรวจสิทธิ์ของตัวแปรข้อมูลที่ต้องจองเก็บไว้ใน heap และทำความเข้าใจว่า Rust รู้ได้อย่างไรว่าจะต้องเข้าไปเก็บกวาดข้อมูลเหล่านั้นทิ้งเวลาใด ซึ่งข้อมูลประเภทชนิด String จัดเป็นกรณีศึกษาตัวอย่างที่ยอดเยี่ยมมาก

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

เราเคยพบเห็นค่าคงที่ข้อความ (string literals) ซึ่งเป็นข้อมูลที่ฝังค่าไว้ลงบนตัวโปรแกรมของเรา ค่าคงที่ข้อความเขียนใช้สะดวกมากแต่ไม่ได้เหมาะสมสำหรับนำมาประยุกต์ใช้งานในทุกๆ สถานการณ์ของการจัดการอักขระและข้อความ เหตุผลแรกคือพวกมันไม่สามารถปรับเปลี่ยนข้อมูลได้ (immutable) และอีกประการคือข้อมูลข้อความบางรายการเราไม่มีทางล่วงรู้ขนาดหรือเนื้อหาข้อมูลล่วงหน้าได้เลยในขณะกำลังเขียนโค้ด เช่น หากเราประสงค์จะขอรับข้อมูลอินพุตจากแป้นพิมพ์ผู้เล่นแล้วนำมาบันทึกเก็บไว้ เป็นต้น เพื่อรับมือกับสถานการณ์ทางเลือกเหล่านี้ Rust จึงเตรียมประเภทข้อมูลชนิด String มาให้ ซึ่งข้อมูลประเภทนี้จะบริหารจัดการข้อมูลด้วยการจองพื้นที่บน heap ส่งผลให้มีความยืดหยุ่นในการจัดเก็บความจุเนื้อหาข้อความปริมาณใดๆ ก็ได้ตามที่ผู้ใช้กรอกเข้ามาโดยไม่ต้องกังวลเรื่องขนาดในช่วงคอมไพล์ คุณสามารถเขียนสร้างออบเจกต์ชนิด String ขึ้นมาจากตัวคงที่ข้อความผ่านการเรียกฟังก์ชัน from ได้ดังนี้:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

สัญลักษณ์เครื่องหมายโคลอนคู่ :: ทำหน้าที่ช่วยระบุจัดกลุ่มขอบเขตพื้นที่ชื่อ (namespace) ของฟังก์ชัน from นี้ให้อยู่ภายใต้สังกัดการทำงานของชนิดข้อมูล String แทนการตั้งชื่อเดี่ยวลอยๆ เช่น string_from เราจะได้มาเรียนรู้หลักไวยากรณ์ข้อกำหนดเหล่านี้ลึกขึ้นในบทที่ 5 หัวข้อ “เมธอด” และขยายความประเด็นโมดูลบริหารจัดการในบทที่ 7 หัวข้อ “พาธสำหรับอ้างอิงไอเทมในแผนผังโมดูล”

ข้อความอักขระประเภทนี้ สามารถ ปรับปรุงแก้ไขเนื้อความภายในได้ดังนี้:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

แล้วจุดแตกต่างแท้จริงของทั้งคู่คืออะไร? เพราะเหตุใดข้อมูลชนิด String ถึงปรับเปลี่ยนข้อมูลได้ แต่อักขระแบบ literals กลับปฏิเสธการแก้ไขข้อมูล? ความแตกต่างที่ว่านั้นเกิดมาจากความแตกต่างด้านการบริหารจัดการขอบเขตบนหน่วยความจำของข้อมูลทั้ง 2 ประเภท

หน่วยความจำและการจัดสรรจองพื้นที่ (Memory and Allocation)

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

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

  • จะต้องส่งคำร้องขอพื้นที่หน่วยความจำไปยัง memory allocator ในช่วงที่โปรแกรมรัน (runtime)
  • เราจำเป็นต้องมีกระบวนการคืนพื้นที่หน่วยความจำนี้กลับคืนไปให้ตัวระบบบริหารจัดการภายหลังจากสิ้นสุดการใช้งาน String ชิ้นนั้นแล้ว

เงื่อนไขข้อแรกนั้นเป็นขอบเขตงานฝั่งเรา: เมื่อเราเริ่มทำการเรียกใช้งานคำสั่ง String::from โครงสร้างการทำงานภายในของมันจะส่งคำร้องขอหน่วยความจำขนาดที่จำเป็นต้องใช้ให้แก่เราโดยอัตโนมัติ ซึ่งนี่เป็นเงื่อนไขพฤติกรรมระดับมาตรฐานสากลทั่วไปในภาษาเขียนโปรแกรมคอมพิวเตอร์เกือบทุกๆ ภาษา

แต่ทว่าในส่วนของข้อตกลงเรื่องเงื่อนไขที่สองจะมีกระบวนการทำงานที่แตกต่างออกไป ในระบบภาษาเขียนโปรแกรมที่มีระบบเก็บขยะอัตโนมัติ (garbage collector - GC) ตัวประมวลผล GC จะคอยดูแลเฝ้าติดตามคัดแยกและล้างหน่วยความจำหมดอายุให้แก่ผู้ใช้งานเองโดยเราไม่ต้องมาคอยคิดถึงเรื่องดังกล่าว แต่ในภาษาอื่นส่วนใหญ่ที่ไม่มีระบบ GC คอยดูแล เป็นความรับผิดชอบอย่างเป็นทางการของนักเขียนโค้ดที่จะต้องจำแนกจุดทำงานให้ได้ว่าหน่วยความจำบริเวณใดสิ้นสุดการใช้งานแล้ว และต้องเขียนระบุคำสั่งคืนพื้นที่หน่วยความจำ (free) คลายออกไปด้วยตนเองสอดคล้องกับพฤติกรรมตอนที่สั่งจอง ซึ่งการจัดการเรื่องนี้ให้สมบูรณ์ปลอดภัยถือเป็นปัญหาโลกแตกที่ทำให้นักพัฒนาโปรแกรมปวดหัวมาอย่างยาวนานในอดีต หากเราลืมใส่คำสั่งส่งคืนพื้นที่ จะเกิดภาวะหน่วยความจำรั่วไหล (memory leak) สิ้นเปลืองพื้นที่หน่วยความจำ หากเราสั่งคืนพื้นที่ไวเกินไป ตัวแปรจะเสียหายใช้งานต่อไม่ได้ และหากเราเผลอสั่งคืนพื้นที่ตำแหน่งเดิมซ้ำสองรอบ ก็จะทำให้โปรแกรมทำงานแครชเกิดบั๊กขึ้นทันที โดยนักเขียนโค้ดจำเป็นต้องประสานคู่การจอง (allocate) และคำสั่งคืนพื้นที่ (free) ให้ตรงคู่สอดรับกันอย่างพอดี 1 ต่อ 1 เสมอ

แต่ภาษา Rust เลือกเดินบนหนทางใหม่ที่แตกต่างออกไป: นั่นคือพื้นที่หน่วยความจำจองจะถูกจัดส่งคืนส่งคืนพิกัดกลับสู่ส่วนกลางโดยอัตโนมัติทันทีที่ตัวแปรผู้ครอบครองสิทธิ์หลุดออกนอกขอบเขตการทำงาน (out of scope) ต่อไปนี้เป็นตัวอย่างการดัดแปลงโปรแกรมขอบเขตจากตารางที่ 4-1 โดยหันมาใช้ชนิด String แทนอักขระสัญลักษณ์คงที่ literals:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

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

หมายเหตุ: ในภาษา C++ รูปแบบพฤติกรรมการปล่อยคืนพื้นที่ทรัพยากรระบบทันทีที่ตัวแปรสิ้นอายุขัยนี้ มักเรียกขานในกลุ่มวิชาการว่า Resource Acquisition Is Initialization (RAII) ฟังก์ชัน drop ของ Rust จะทำงานสอดรับและมีทิศทางทำนองเดียวกันกับหลักการรูปแบบ RAII นี้หากคุณเคยมีประสบการณ์ใช้งานมาก่อน

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

ปฏิสัมพันธ์ระหว่างตัวแปรกับชิ้นข้อมูลในรูปแบบการย้ายสิทธิ์ (Move)

ภายใต้ระบบภาษา Rust ตัวแปรหลายตัวสามารถระบุเชื่อมโยงเข้าหาชุดข้อมูลชิ้นเดียวกันได้หลากหลายแนวทาง รายการที่ 4-2 แสดงตัวอย่างพฤติกรรมนี้โดยประยุกต์ร่วมกับตัวแปรชนิดเลขจำนวนเต็ม

fn main() {
    let x = 5;
    let y = x;
}

เราน่าจะคาดเดาความหมายของการประมวลผลโค้ดนี้ได้ไม่ยาก: “ผูกข้อมูลค่าตัวเลข 5 ให้แก่ตัวแปร x จากนั้นทำคัดลอกโคลนข้อมูลจากตัวแปร x แล้วนำค่าที่ได้ไปผูกให้ตัวแปร y” ตอนนี้ส่งผลให้เรามีตัวแปรจำนวน 2 ตัวคือ x และ y ซึ่งต่างเก็บตัวเลข 5 ไว้คู่กัน ซึ่งนี่เป็นไปตามความเข้าใจของเรา เนื่องจากชนิดข้อมูลจำนวนเต็มจัดเป็นมูลค่าข้อมูลอย่างง่ายที่มีขนาดพิกัดคงที่แน่นอนในระบบ ส่งผลให้ผลลัพธ์ข้อมูล 5 ทั้งคู่จะถูกจัดการพุชพ่วงตั้งเก็บซ้อนไว้ใน stack ทันที

คราวนี้สลับลองมาดูในเวอร์ชันที่เปลี่ยนมาใช้งานชนิด String แทนกันบ้าง:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

ลองมาตรวจสอบพิจารณาจากภาพแผนภาพที่ 4-1 เพื่อศึกษาทิศทางการเคลื่อนไหวของ String ภายใต้ระบบอย่างละเอียด ข้อมูลชนิด String จะถูกประกอบสร้างขึ้นจากชิ้นส่วนข้อมูล 3 องค์ประกอบหลัก (แสดงในตารางฝั่งซ้ายมือ) ได้แก่: ข้อมูลตัวชี้พิกัดหน่วยความจำ (pointer) ไปยังตำแหน่งเก็บเนื้อข้อความจริง, ขนาดความยาวข้อมูลข้อความปัจจุบัน (length) และความสามารถจัดเก็บของหน่วยความจำสูงสุด (capacity) โดยที่กลุ่มข้อมูลสารสนเทศแถบซ้ายนี้ทั้งหมดจะได้รับการบันทึกจัดเก็บจองพื้นที่ไว้บน stack และพื้นที่ฝั่งขวามือคือบริเวณของเนื้อที่บน heap ที่ใช้เก็บข้อมูลตัวอักษรเนื้อความข้อความจริง

Two tables: the first table contains the representation of s1 on the stack, consisting of its length (5), capacity (5), and a pointer to the first value in the second table. The second table contains the representation of the string data on the heap, byte by byte.

รูปภาพที่ 4-1: การแทนค่าในหน่วยความจำของ String ที่เก็บค่า "hello" ซึ่งผูกไว้กับ s1

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

เมื่อเราเขียนป้อนประโยคสั่งผูกค่า s1 ไปให้แก่ s2 ข้อมูลตัวแปรหลักของ String จะถูกคัดลอกส่งผ่านไป ซึ่งหมายความว่าระบบจะคัดลอกเฉพาะข้อมูล pointer, ค่าความยาว length และค่าสูงสุด capacity ที่วางตัวจัดเก็บอยู่บน stack เท่านั้น ระบบจะ ไม่ได้ คัดลอกชิ้นงานข้อมูลอักขระข้อความจริงที่เก็บอยู่บน heap ไปด้วย กล่าวคือแผนผังข้อมูลบนหน่วยความจำจริงจะมีลักษณะตามรูปภาพที่ 4-2:

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap.

รูปภาพที่ 4-2: การแทนค่าในหน่วยความจำของตัวแปร s2 ที่ได้คัดลอก pointer, ความยาว และขนาดความจุสูงสุดของ s1 มาใช้งาน

รูปแบบแผนผังการแทนค่าหน่วยความจำนี้จะ ไม่ได้ ออกมาในรูปแบบที่เห็นในรูปภาพที่ 4-3 ซึ่งโครงสร้างรูปภาพนั้นจะเกิดขึ้นในกรณีที่ Rust เลือกที่จะโคลนคัดลอกชิ้นงานข้อมูลอักษรบน heap ซ้ำไปจัดสรรพื้นที่เพิ่มขึ้นมาอีกชุดหนึ่งด้วย หาก Rust ดำเนินการขั้นตอนคัดลอกลักษณะนั้น การสั่งคีย์ประโยค s2 = s1 จะก่อให้เกิดการหน่วงประสิทธิภาพทำงานรุนแรงมากในช่วงรันโปรแกรม (runtime) หากชิ้นงานข้อมูลข้อความบน heap มีขนาดความยาวที่ใหญ่มาก

Four tables: two tables representing the stack data for s1 and s2, and each points to its own copy of string data on the heap.

รูปภาพที่ 4-3: ความเป็นไปได้อีกประการหนึ่งของคำสั่ง s2 = s1 หากภาษา Rust เลือกที่จะสั่งคัดลอกข้อมูลอักขระใน heap ซ้ำขึ้นมาด้วย

ก่อนหน้านี้เราได้อธิบายไปแล้วว่าเมื่อตัวแปรหลุดขอบเขตงาน Rust จะเข้ามาจัดการเรียกใช้งานฟังก์ชัน drop คืนพื้นที่หน่วยความจำ heap ให้โปรแกรมเองโดยอัตโนมัติ แต่จากรูปภาพที่ 4-2 แสดงพิกัดให้เห็นว่าตัวชี้ pointer ของข้อมูลตัวแปรทั้งคู่กำลังระบุชี้ไปที่พิกัดช่องหน่วยความจำฮีปเดียวกันพอดิบพอดี ซึ่งพฤติกรรมนี้จะก่อเกิดปัญหาใหญ่ขึ้น: นั่นคือเมื่อทั้ง s2 และ s1 หลุดออกขอบเขตงานไปตามลำดับ ตัวแปรทั้งสองจะพยายามสั่งปลดปล่อยล้างคืนหน่วยความจำที่ช่องเดิมซ้ำซ้อนกัน ข้อผิดพลาดนี้เรียกว่า ข้อผิดพลาดการล้างหน่วยความจำซ้ำซ้อน (double free error) ซึ่งเป็นหนึ่งในช่องโหว่ความปลอดภัยของหน่วยความจำ (memory safety) ร้ายแรงที่เราพูดถึงก่อนหน้านี้ การคืนพิกัดหน่วยความจำเดิมซ้ำสองรอบอาจส่งผลให้โปรแกรมเสียหายรุนแรง (memory corruption) และอาจเปิดทางสู่ภัยคุกคามสิทธิ์เข้าถึงระบบความปลอดภัยของระบบโปรแกรม

เพื่อปิดความเสี่ยงภัยประเด็นหน่วยความจำและรับประกันความปลอดภัยอย่างสูงสุด ภายหลังจากบรรทัดคำสั่ง let s2 = s1; ประมวลผลเสร็จ Rust จะทำประกาศประวัติรายงานทันทีว่าตัวแปร s1 มีสถานะ ถูกยกเลิกการใช้งานและใช้งานไม่ได้อีกต่อไป (no longer valid) ส่งผลให้คอมไพเลอร์ Rust ไม่มีความจำเป็นที่จะต้องสั่งล้างหน่วยความจำใดๆ อีกเลยเมื่อถึงรอบขอบเขตปิดตัวแปร s1 ลองมาสังเกตดูพฤติกรรมว่าจะเกิดอะไรขึ้นหากคุณยังคงฝืนฝอยเขียนสั่งใช้งานตัวแปร s1 ภายหลังจากที่ตั้งตัวแปร s2 ไปแล้ว ซึ่งจะพบว่าคอมไพเลอร์ปฏิเสธรันงานคอมไพล์ผ่าน:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

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

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

หากคุณเคยได้ยินคำศัพท์คำว่า การคัดลอกแบบตื้น (shallow copy) และ การคัดลอกแบบลึก (deep copy) จากการเรียนรู้ภาษาเขียนโปรแกรมอื่นมาก่อน พฤติกรรมการคัดลอกสติกข้อมูล pointer, length และ capacity จากสแตกโดยหลีกเลี่ยงการก๊อปปี้เนื้อความใน heap อาจจะฟังดูคล้ายคลึงกับการทำ shallow copy ทั่วไปมาก แต่เนื่องจาก Rust เลือกที่จะจัดการยับยั้งสิทธิ์และยกเลิกสถานะของตัวแปรต้นขั้วแรกทิ้งไปด้วยเพื่อความปลอดภัย พฤติกรรมการทำงานลักษณะนี้ในภาษา Rust จึงไม่เรียกว่า shallow copy แต่จะนิยามว่าเป็นการกระทำแบบ การย้ายสิทธิ์ (move) ในกรณีตัวอย่างนี้เราจะกล่าวว่า s1 ได้ถูก ย้ายสิทธิ์ (moved) ไปบรรจุอยู่ในตัวแปร s2 เรียบร้อยแล้ว ซึ่งทิศทางการทำงานแผนผังข้อมูลบนหน่วยความจำจริงแสดงให้ดูในรูปภาพที่ 4-4:

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap. Table s1 is grayed out because s1 is no longer valid; only s2 can be used to access the heap data.

รูปภาพที่ 4-4: การแทนค่าในหน่วยความจำภายหลังตัวแปร s1 ถูกยกเลิกความสมบูรณ์สิทธิ์การใช้งาน

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

ยิ่งไปกว่านั้น การตัดสินใจสเปกเงื่อนไขการทำงานในจุดนี้ได้ซ่อนนัยยะของการออกแบบภาษา Rust ไว้ประการหนึ่งคือ: Rust จะไม่มีวันสั่งสร้าง “การคัดลอกแบบลึก (deep copy)” ให้แก่ข้อมูลของคุณโดยอัตโนมัติอย่างเด็ดขาด ส่งผลให้ขั้นตอนกระบวนการคัดลอกข้อมูลโดยอัตโนมัติ (automatic copying) ใดๆ ในระบบของ Rust จะทำงานได้ว่องไวและประหยัดกำลังเครื่องเป็นที่มั่นใจได้เสมอ

ขอบเขตการทำงานและการมอบค่าตัวแปร

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

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

เริ่มต้นโปรแกรมเราประกาศตั้งตัวแปร s พร้อมผูกข้อมูลเข้ากับชนิด String มูลค่า "hello" หลังจากนั้นเราป้อนรหัสสร้างข้อความชุดใหม่มูลค่า "ahoy" มาบันทึกกำหนดทับลงตัวแปร s ทันที ณ จุดนี้ส่งผลให้ข้อมูลชุดดั้งเดิม "hello" บน heap ตกอยู่ในสภาวะไม่มีตัวแปร pointer ชิ้นใดระบุชี้ไปหาอีกต่อไป แผนผังการจัดสรรสิทธิ์แสดงได้ตามรูปภาพที่ 4-5:

One table representing the string value on the stack, pointing to the second piece of string data (ahoy) on the heap, with the original string data (hello) grayed out because it cannot be accessed anymore.

รูปภาพที่ 4-5: การแทนค่าในหน่วยความจำภายหลังค่าเริ่มต้นชุดแรกโดนระบุป้อนทับเขียนแทนที่แบบสมบูรณ์

อักขระตัวข้อความเริ่มแรกจึงนับเป็นหลุดขอบเขตการทำงานโดยปริยาย Rust จะดำเนินการเรียกใช้คำสั่งทำงานของฟังก์ชัน drop บนพื้นที่ช่องหน่วยความจำดั้งเดิมนั้นเพื่อเคลียร์พื้นที่คืนทันที และในบรรทัดสุดท้ายของการแสดงข้อความ ผลลัพธ์ข้อมูลที่พิมพ์ปรากฏคือคำว่า "ahoy, world!"

ปฏิสัมพันธ์ระหว่างตัวแปรกับชิ้นข้อมูลในรูปแบบการโคลนคัดลอก (Clone)

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

ตัวอย่างการเรียกใช้งานเมธอด clone มีพฤติกรรมดังนี้:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

โค้ดส่วนนี้จะประมวลผลงานผ่านฉลุย และแสดงพฤติกรรมการทำงานลอกเลียนตรงตามรูปแบบที่เราวาดให้เห็นในรูปภาพที่ 4-3 ทุกประการ นั่นคือข้อมูลเนื้อหาอักขระใน heap จะถูกทำจัดทำสำเนาคัดลอกจองพื้นที่ใหม่เพิ่มขึ้นมาคู่กันจริงๆ

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

ชิ้นข้อมูลประเภทจำกัดตัวเฉพาะสแตก: การทำก๊อปปี้ (Copy)

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

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

แต่พฤติกรรมโค้ดส่วนนี้กลับดูขัดแย้งกับหลักการครอบครองสิทธิ์ที่เราเพิ่งทำความเข้าใจไปครู่ใหญ่: ในแถวคำสั่งเราไม่มีการป้อนระบุสั่งใช้เมธอด clone เลย ทว่าตัวแปร x กลับยังคงแสดงสถานะใช้งานได้อย่างถูกต้องสมบูรณ์และสิทธิ์ข้อมูลก็ไม่ได้ถูกสั่งให้ย้ายพ้นไปเก็บไว้ที่ตัวแปร y

เหตุผลก็เป็นเพราะว่าข้อมูลประเภทจำนวนสเกลาร์อย่างตัวเลขจำนวนเต็ม ซึ่งมีขนาดความกว้างจำกัดแน่นอนตั้งแต่ช่วงวิเคราะห์โค้ดจะถูกนำไปจัดเก็บจองพื้นที่จัดสรรทั้งหมดไว้บน stack เท่านั้น ส่งผลให้การทำสำเนาคัดลอกชิ้นค่าข้อมูลจริงทำงานได้รวดเร็วมากและแทบไม่ส่งผลกระทบต่อภาระเครื่อง ด้วยลักษณะพฤติกรรมดังกล่าวระบบจึงไม่มีแรงจูงใจที่จะต้องเข้ามาสกัดยกเลิกสถานะของตัวแปร x ทิ้งไปหลังจากป้อนระบุตัวแปร y กล่าวอีกนัยหนึ่งคือ การกระทำโคลนแบบตื้นและโคลนแบบลึกไม่มีความแตกต่างในเชิงประสิทธิภาพกันเลยสำหรับข้อมูลชนิดจำกัดเฉพาะสแตกนี้ ดังนั้นการเขียนเรียกใช้ clone หรือไม่ จึงให้ผลลัพธ์ไม่ได้แตกต่างกัน และเราสามารถละทิ้งการเขียนระบุมันออกนอกบรรทัดได้เลย

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

ทั้งนี้คอมไพเลอร์ Rust จะไม่อนุญาตให้ประกาศแนบตราคุณสมบัติ Copy ให้แก่ชนิดประเภทใดๆ ที่มีการประกาศพฤติกรรมของ Drop เทรตไว้ข้างใน หรือมีองค์ประกอบย่อยส่วนใดส่วนหนึ่งที่เรียกใช้งานสิทธิ์คืนหน่วยความจำ Drop หากประเภทชนิดดังกล่าวมีพฤติกรรมเฉพาะถิ่นที่ต้องคืนหน่วยความจำเมื่อหลุดออกนอกขอบเขตการทำงาน แล้วเราพยายามจะไปบังคับยัดตราประทับ Copy พ่วงให้คอมไพเลอร์ Rust จะส่งคืนข้อผิดพลาดช่วงคอมไพล์ทันที สามารถศึกษาวิธีเขียนระบุตรากำกับ Copy พ่วงให้แก่ประเภทข้อมูลของคุณใน “คุณลักษณะเทรตที่อนุมานสร้างขึ้นเองได้ (Derivable Traits)” ในภาคผนวก C

แล้วมีชนิดข้อมูลประเภทใดบ้างที่ได้รับตราประทับคุณสมบัติ Copy เทรตนี้พ่วงมาด้วยตั้งแต่เริ่มแรก? คุณสามารถตรวจสอบประวัติชนิดนั้นๆ ได้ในเอกสารคู่มือของภาษาได้โดยตรงเพื่อความแม่นยำ แต่โดยเกณฑ์มาตรฐานทั่วไปแล้ว ชนิดประเภทข้อมูลเดี่ยวสเกลาร์ (scalar values) อย่างง่ายทั้งหมดจะได้รับสิทธิ์แนบคุณสมบัติ Copy และข้อมูลส่วนใดๆ ก็ตามที่จำเป็นต้องร้องขอพื้นที่จองหน่วยความจำ heap หรือทำหน้าที่เป็นทรัพยากรหลักภายนอกระบบจะปฏิเสธการสวมสิทธิ์ Copy ตัวอย่างชนิดข้อมูลทั่วไปที่เป็นกลุ่ม Copy ได้แก่:

  • ชนิดตัวเลขจำนวนเต็มทั้งหมด เช่น u32
  • ชนิดข้อมูลค่าความจริง bool ซึ่งถือครองค่า true และ false
  • ชนิดข้อมูลเลขทศนิยมทั้งหมด เช่น f64
  • ชนิดข้อมูลอักขระตัวอักษร char
  • กลุ่มทูเพิล (Tuples) หากสมาชิกทุกๆ ตัวภายในทูเพิลนั้นมีชนิดข้อมูลที่อยู่ในกลุ่ม Copy ด้วยเช่นกัน เช่น ทูเพิลสเปก (i32, i32) จะมีสถานะเป็น Copy แต่ทูเพิลขนาด (i32, String) จะไม่ได้สิทธิ์ความเป็น Copy

สิทธิ์การครอบครองหน่วยความจำและฟังก์ชัน (Ownership and Functions)

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

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

หากคุณพยายามที่จะเขียนดึงใช้งานตัวแปร s อีกครั้งในบรรทัดด้านล่างที่อยู่ถัดจากการเรียกฟังก์ชัน takes_ownership คอมไพเลอร์ Rust จะรายงานความล้มเหลวไม่อนุญาตให้รันโค้ดผ่านได้ทันที ด่านกรองตรวจสอบสิทธิ์ (static checks) คอยคุ้มครองคุณจากความพลั้งพลาด ให้ลองเสริมโค้ดทดสอบลงในขอบเขตฟังก์ชัน main โดยเขียนเรียกใช้งานตัวแปร s และ x เพื่อร่วมตรวจสอบศึกษาขอบเขตจุดที่คุณสามารถดึงเรียกพวกมันไปประมวลผลได้จริง และพิกัดตำแหน่งใดที่กฎสิทธิ์การครอบครองหน่วยความจำเริ่มเข้ามาสกัดกั้นไม่ให้ใช้

ค่าผลลัพธ์ขากลับและขอบเขตการทำงาน (Return Values and Scope)

ขั้นตอนการส่งคืนค่าขากลับของฟังก์ชัน (returning values) ก็มีผลต่อการส่งมอบถ่ายโอนสิทธิ์การครอบครองหน่วยความจำ (transfer ownership) เช่นกัน รายการที่ 4-4 แสดงตัวอย่างโครงสร้างฟังก์ชันขากลับ พร้อมคู่มือคำอธิบายประวัติสิทธิ์และขอบเขตคล้ายตัวอย่างก่อนหน้า:

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

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

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

ภาษา Rust อนุญาตให้เราส่งคืนผลลัพธ์ขากลับได้หลากหลายตัวพร้อมๆ กันโดยการรวมรายการจัดเก็บมาในทูเพิล ดังแสดงตัวอย่างในรายการที่ 4-5:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

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