สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video
Common Collection
https://doc.rust-lang.org/book/ch08-00-common-collections.html
ใน Rust มี Collection ที่ใช้งานกันอย่างแพร่หลาย ซึ่งเป็นโครงสร้างข้อมูลที่ช่วยให้เราสามารถจัดเก็บและจัดการข้อมูลได้อย่างมีประสิทธิภาพ โดย Collection ใน Rust มักพบใน standard library (std::collections
) และมีความปลอดภัยเพราะใช้ระบบ ownership และ borrowing ของ Rust ช่วยลดข้อผิดพลาดได้
ซึ่งเอาจริงๆ Collection ที่มีอยู่ใน Rust มีอยู่พอสมควร แต่เราจะขอหยิบ 3 ตัวที่มีการใช้งานบ่อยๆ เพื่อให้เห็นภาพของการใช้งาน collection กัน
Vectors
Vector
หรือ Vec<T>
ใน Rust เป็น collection ชนิดหนึ่งที่ใช้สำหรับเก็บลำดับของข้อมูลประเภทเดียวกัน (homogeneous data) โดยมีขนาดที่สามารถเปลี่ยนแปลงได้ (dynamic size) ต่างจาก array ที่มีขนาดคงที่ นอกจากนี้ Vector มี function และคุณสมบัติที่ช่วยให้เราจัดการข้อมูลได้อย่างสะดวก เช่น การเพิ่ม, ลบ, และแก้ไขข้อมูลในลำดับได้
ความสามารถเด่นๆของ Vector คือ
- Dynamic Size: เพิ่มหรือลบข้อมูลได้โดยไม่ต้องกำหนดขนาดล่วงหน้าได้
- Efficient Memory Management: Rust ใช้ระบบ ownership และ borrowing เพื่อจัดการหน่วยความจำของ Vector ที่ยังคงทำให้อยู่ภายในระบบ ownership ได้ แม้ว่าจะมีการ dynamic ที่ส่วนของ Memory ก็ตาม
- Generic Type: รองรับการจัดเก็บข้อมูลทุกประเภท เช่น
Vec<i32>
,Vec<String>
(เดี๋ยวเราจะอธิบายเรื่อง Generic กันใน topic หลังๆอีกที)
ตัวอย่างการใช้ Vector
ตัวอย่างที่ 1: ใช้ Vec::new()
หรือ vec![]
เพื่อสร้าง Vector
Note
- สังเกตว่า fruits เราไม่ได้มีการประกาศประเภทของ Vector เอาไว้ แต่เมื่อเราเก็บข้อมูลเป็นประเภทเดียวกัน compiler จะสามารถรับรู้ว่าเป็นประเภท
&str
และสามารถกำหนดเป็นVec<&str>
ออกมาได้
ตัวอย่างที่ 2: การเพิ่ม, การเข้าถึงข้อมูล, การลบข้อมูล, การปรับเปลี่ยนข้อมูล ใน Vector
ผลลัพธ์ที่ได้
Note
- การเพิ่มข้อมูล: ใช้
push()
เพื่อเพิ่มค่าเข้าไปใน Vector - การเข้าถึงข้อมูล: ใช้
[]
หรือget()
เพื่อตรวจสอบค่าที่ตำแหน่งต่าง ๆ - การวน loop ข้อมูล: ใช้
for
เพื่อวน loop ค่าภายใน Vector - การปรับเปลี่ยนข้อมูล: ใช้
last_mut()
เพื่อปรับค่าตำแหน่งสุดท้าย - การลบข้อมูล: ใช้
pop()
เพื่อลบค่าตำแหน่งสุดท้าย หรือremove()
เพื่อลบค่าที่ตำแหน่งที่ต้องการ
ข้อควรระวังในการใช้ Vector
- Index Out of Bounds: หากเข้าถึงตำแหน่งที่เกินขอบเขตของ Vector จะทำให้โปรแกรม panic ได้
- Ownership: หากต้องการให้ Vector ถูกใช้งานหลายที่ ต้องใช้
borrow
หรือclone
(ตามหลักการของ Ownership)
Strings
ใน Rust String
เป็นหนึ่งในชนิดข้อมูลสำหรับจัดเก็บ ข้อความ ซึ่งสามารถเปลี่ยนแปลงขนาดได้ (dynamic size) ต่างจาก &str
ที่เป็น string slice ซึ่งมีขนาดคงที่และอ้างอิงไปยังข้อมูลที่อยู่ในหน่วยความจำ
String
: ข้อมูลที่จัดการหน่วยความจำเอง และสามารถเพิ่มหรือลดขนาดได้&str
(String Slice): ข้อมูลที่อ้างอิงข้อความ เช่น ตัวอักษรใน literal string ("Hello"
)
ความสามารถเด่นๆของ String
- Dynamic Size: สามารถเพิ่มหรือลดข้อความได้
- Ownership:
String
มี ownership ของข้อมูล และจะจัดการหน่วยความจำเอง - UTF-8 Encoding: String ใน Rust เข้ารหัสแบบ UTF-8 ทำให้รองรับอักขระจากหลายภาษา
ตัวอย่างการใช้ String
ตัวอย่างที่ 1: การสร้าง และการเพิ่มข้อความใน String
ผลลัพธ์
ตัวอย่างที่ 2: เข้าถึงข้อมูล, การเชื่อมข้อความ, และ การลบข้อความ ใน String
ผลลัพธ์ที่ได้
Note
- ใช้
push_str()
เพื่อเชื่อมข้อความใหม่ - ใช้
.chars().next()
เพื่อเข้าถึงตัวแรก, ใช้.chars().last()
เพื่อเข้าถึงตัวสุดท้าย - ใช้ slicing
[start..end]
เพื่อดึงข้อความบางส่วน - ใช้
replace_range(start..end, "new_text")
เพื่อลบข้อความในช่วงที่กำหนดและแทนที่ด้วยข้อความใหม่ - ใช้
pop()
เพื่อลบอักขระสุดท้ายของข้อความ
ตัวอย่างที่ 3: การแปลงชนิดระหว่าง String
และ &str
Note
- ใช้**
.to_string()
** เพื่อแปลง&str
ให้เป็นString
โดย.to_string()
จะสร้าง String object ใหม่ ที่จัดการหน่วยความจำและสามารถเปลี่ยนแปลงขนาดได้ ผลลัพธ์ที่ได้คือข้อความเดียวกันในชนิดString
- การใช้เครื่องหมาย
&
เพื่อสร้าง String slice (&str
) ที่อ้างอิงข้อมูลในString
การแปลงนี้ไม่มีการคัดลอกข้อมูล (copy) เพราะ&str
เป็นเพียงการอ้างอิงตำแหน่งในหน่วยความจำของString
&str
ที่สร้างขึ้นจะยังคงใช้ได้ตราบเท่าที่String
ที่มันอ้างอิงยังคงมีอยู่ (เดี๋ยวจะมีอธิบายเพิ่มเติมในหัวข้อ lifetime)
Hash maps
HashMap
ใน Rust เป็นโครงสร้างข้อมูลที่ใช้เก็บ key-value pairs โดยสามารถค้นหา, เพิ่ม, และลบข้อมูลโดยใช้ hashing เพื่อจัดการข้อมูลและการค้นหา
โดย HashMap
อยู่ใน standard library (std::collections::HashMap
) และรองรับ key และค่าที่มีประเภทใด ๆ ที่เป็น Hashable และ Eq เช่น String
, i32
, หรือชนิดข้อมูลที่กำหนดเองที่ implement ตาม traits เหล่านี้ขึ้นมา (เดี๋ยวจะอธิบายเรื่อง trait อีกที)
ความสามารถเด่นๆของ HashMap
- Key-Value Pairs: เก็บข้อมูลในรูปแบบคู่ (key, value)
- Unique Keys: key ต้องไม่ซ้ำกันใน HashMap
- Dynamic Size: สามารถเพิ่มหรือลบคู่ key-value ได้ตามต้องการ
- Fast Lookup: ใช้เวลาค้นหาใกล้เคียง O(1) ในกรณีทั่วไป
ตัวอย่างการใช้งาน HashMap
ตัวอย่างที่ 1: การสร้าง HashMap และเพิ่มข้อมูล
ผลลัพธ์
ตัวอย่างที่ 2: การเข้าถึงข้อมูล, การวน loop, และ การอัปเดตและการลบ ใน HashMap
ผลลัพธ์
Note
- ใช้
get(key)
เพื่อดึงค่าที่ต้องการจาก HashMap - ใช้
if let
เพื่อจัดการกรณีที่ key ไม่มีใน HashMap - ใช้
for
เพื่อวน loop HashMap โดยแต่ละรอบจะได้(key, value)
เป็นคู่ออกมา - ใช้
insert(key, value)
เพื่อเพิ่มหรือแทนค่าของ key ที่มีอยู่ - ใช้
entry(key).or_insert(value)
เพื่อเพิ่มค่าถ้า key ยังไม่มีอยู่ - ใช้
remove(key)
เพื่อลบคู่ key และ value ที่ต้องการออกจาก HashMap
และนี่ก็คือ collection พื้นฐานที่มักจะมีการใช้งาน Rust สามารถดู collection อื่นๆเพิ่มเติมใน Rust Book หรือ document อื่นๆของ Rust เพิ่มเติมได้ (หรือสามารถศึกษาตาม use case ที่มีการใช้งานเพิ่มเติมก็ได้เช่นเดียวกัน)
Generics
https://doc.rust-lang.org/book/ch10-00-generics.html
Generics ใน Rust คือกลไกที่ช่วยให้เราสามารถเขียน code ที่รองรับหลายชนิดข้อมูล (data types) โดยที่”ไม่ต้องเขียน code ซ้ำซ้อน” กันได้ Generics ทำให้ function, โครงสร้างข้อมูล (struct), และ enum สามารถทำงานได้กับชนิดข้อมูลที่หลากหลาย โดยที่ยังคงความปลอดภัยและประสิทธิภาพสูงสุดเอาไว้ได้
โดยหลักการของ Generic ใน Rust คือ
- Parametric Polymorphism: Generics ช่วยให้โ code สามารถทำงานกับชนิดข้อมูลต่าง ๆ ได้โดยไม่ระบุชนิดข้อมูลล่วงหน้าได้
- Rust จะ monomorphize code (แปลงเป็นชนิดข้อมูลเป้าหมาย) ในขั้นตอน compile ทำให้ประสิทธิภาพไม่ลดลง และยังได้คุณสมบัตินี้จาก Compile Time เหมือนเดิม หรือ เล่าอีกอย่างคือ Generic แม้จะไม่ได้มีการระบุประเภทของประกาศออกมา แต่เมื่อตอน Compile state compile จะทำการสร้าง type ตามที่มีการใช้งานใน Generic ขึ้นมา เพื่อให้ไม่ต้องไปคำนวนใหม่ในตอนจังหวะ runtime นั่นเอง
โดยเป้าหมายของการใช้ Generic คือ
- Code Reusability: Generics ช่วยให้เราเขียน code ที่นำกลับมาใช้ซ้ำได้โดยไม่ต้องระบุชนิดข้อมูลหลายครั้งได้
- Type Safety: Rust จะตรวจสอบชนิดข้อมูลในจังหวะ compile ทำให้สามารถหลีกเลี่ยงข้อผิดพลาดได้เช่นเดิม
- Performance: Generics ใน Rust ถูก monomorphized ในขั้นตอนคอมไพล์ ซึ่งหมายความว่าไม่มี overhead ใน runtime เกิดขึ้น
ตัวอย่างการใช้งาน Generic
ผลลัพธ์
Note
T
เป็น Generic Type Parameter ที่สามารถเป็นชนิดข้อมูลใดก็ได้- function
print_two_values
รองรับชนิดข้อมูลที่หลากหลายโดยไม่ต้องระบุชนิดล่วงหน้า
ตามที่เห็นใน code จะเห็นว่า Generic นั้นส่งผลทำให้เรามีความสามารถในการรับข้อมูลแบบยืดหยุ่นเอาไว้ได้ และทำให้สามารถ support ข้อมูลหลากหลายประเภทใน function เอาไว้ได้ ส่งผลทำให้เราจัดการข้อมูลกับหลายประเภทไปพร้อมๆกันได้โดยที่ไม่จำเป็นต้องสร้าง code แยกเพื่อทำการ handle ในแต่ละประเภทมา (เนื่องจาก Rust เป็นภาษาที่ต้องมีการระบุ type เสมอ เพื่อคงคุณสมบัติของ type safety เอาไว้)
เพื่อให้เห็นภาพมากขึ้น โดยปกติ Generic มักใช้ร่วมกับ Struct เพื่อให้สามารถ handle ข้อมูลหลายๆประเภทไว้ในโครงสร้างเดียวกันได้ ตัวอย่างต่อไป เราจะลองมาดูการใช้งานร่วมกับ Struct กัน
การใช้ Struct, Enum กับ Generic
การใช้ Struct กับ Generic ใน Rust คือการทำให้ Struct รองรับการจัดเก็บข้อมูลหลายชนิด (data types) โดยไม่ต้องระบุชนิดข้อมูลล่วงหน้าเมื่อกำหนด Struct ช่วยให้โครงสร้างข้อมูลสามารถยืดหยุ่นและนำไปใช้งานซ้ำได้กับหลายชนิดข้อมูลได้
โดย concept ของการใช้ Generic ใน Struct คือ
- ใช้ Generic Type Parameter (
<T>
) กับ Struct โดยT
เป็นตัวแทนสำหรับชนิดข้อมูลที่จะถูกกำหนดในตอนใช้งานจริง - Struct ที่มี Generic สามารถเก็บข้อมูลชนิดใด ๆ ที่กำหนดให้ในตอนสร้างได้
เช่น ตัวอย่างนี้ เราจะประกาศ Struct Point<T>
รองรับข้อมูลทั้ง i32
และ f64
โดยไม่ต้องสร้าง Struct แยกออกมา ก็จะส่งผลทำให้ สามารถรองรับข้อมูลทั้ง 2 ประเภทได้
ผลลัพธ์
รวมถึงสามารถใช้กับ Enum ได้ด้วยเช่นกัน
ผลลัพธ์
สังเกตว่า ตอน handle error ในหัวข้อก่อนหน้านี้เองก็จะเป็นการประกาศ Enum Option ลักษณะนี้เหมือนกัน เพื่อใช้สำหรับการ handle error ในประเภทต่างๆเอาไว้ได้ โดยใช้รูปแบบข้อมูลที่มีโครงสร้าง Generic เช่น Result
และ Option
เพื่อจัดการข้อผิดพลาดได้
Trait
อยู่ภายในหมวด Generic เช่นกัน https://doc.rust-lang.org/book/ch10-00-generics.html
Trait เป็นเหมือน interface ในภาษาโปรแกรมอื่น ๆ ซึ่งกำหนดชุดของ function ที่ประเภทต่าง ๆ สามารถนำมาใช้ได้ โดยการกำหนด Trait ทำให้เราสามารถสร้างพฤติกรรมร่วมกันระหว่างประเภทต่าง ๆ ได้
ลักษณะเด่นๆของ Trait
- การกำหนดพฤติกรรม: Trait เป็นการอธิบายว่าอะไรที่ประเภทควรจะสามารถทำได้ เช่น การคำนวณ การเปรียบเทียบ หรือการพิมพ์ข้อความ
- การใช้งานร่วมกับ Generic: Trait มักใช้ใน Generic Bound เพื่อบังคับว่าประเภทที่ใช้ต้องมีพฤติกรรมตามที่กำหนดใน Trait จึงจะสามารถใช้งานได้
- การใช้งาน (Implementation): ประเภทที่ต้องการพฤติกรรมของ Trait จะต้อง implement function ใน Trait เหล่านั้น
เช่น ตัวอย่าง code นี้ เป็นส่วน code สำหรับสร้าง trait
จาก code นี้ จะทำการกำหนด
trait Greet
กำหนดพฤติกรรมsay_hello
ที่ประเภทใด ๆ ที่ implement ต้องมี&self
คือการรับค่าตัวเอง (เหมือนthis
ในภาษาอื่น)
เมื่อนำ function นั้นมา implement trait
Note
impl Greet for Person
บอกว่าPerson
จะ implement TraitGreet
say_hello
ถูกกำหนดให้ทำงานตามที่ Trait ระบุไว้
เมื่อจังหวะมีการเรียกใช้งาน
ผลลัพธ์
สังเกตว่า เราก็จะสามารถใช้ function ที่มีการกำหนดไว้ใน trait ที่ทำการ implement คู่กับ Struct เอาไว้ได้
สำหรับผู้ที่คุ้นเคยกับ OOP (Object-Oriented Programming) สามารถเปรียบ Trait ใน Rust ได้ว่าเป็นการรวมคุณสมบัติของ Interface และ Abstract Class จากภาษาอื่น ๆ เช่น Java หรือ C# ได้ โดย Trait จะทำหน้าที่กำหนดชุดพฤติกรรม (methods) ที่ประเภทใด ๆ (struct หรือ enum) ต้องนำไปใช้ โดย “ไม่มีการเก็บสถานะ (state) เหมือนกับ Interface ใน OOP” แต่ก็สามารถกำหนด Default Implementation ให้กับ methods ได้ เช่นเดียวกับ Abstract Class ซึ่งช่วยลดการเขียน code ซ้ำในกรณีที่พฤติกรรมมีลักษณะเดียวกันได้
Trait กับ Generic
การใช้งาน Trait กับ Generic ใน Rust ช่วยให้ code ยืดหยุ่นและสามารถรองรับประเภทข้อมูลต่าง ๆ ได้โดยบังคับว่าประเภทที่นำมาใช้ต้องมีพฤติกรรมตามที่กำหนดใน Trait นั้น ๆได้ โดยคุณสมบัตินี้สามารถทำได้ผ่านสิ่งที่เรียกว่า Trait Bound
Trait Bound ใช้กำหนดว่า Generic Type ต้อง implement Trait ใด Trait หนึ่ง เพื่อให้ function หรือ struct สามารถเรียกใช้งาน method ที่กำหนดใน Trait ได้
เช่น ดัง code ตัวอย่างนี้
Note
T: Summable
กำหนดว่าT
ต้อง implement TraitSummable
- function
print_sum
สามารถเรียกใช้ methodsum
ได้เพราะ Trait Bound รับประกันว่าT
มี method นี้
Trait Bound ใน Generic ใช้เพื่อป้องกันข้อผิดพลาดที่อาจเกิดจากการใช้ Generic Type ที่ไม่มีพฤติกรรมหรือ function ตามที่ต้องการ โดย Trait Bound บังคับให้ประเภทที่ถูกใช้ใน Generic ต้อง implement Trait ที่ระบุไว้ เช่น
- ถ้าเป็นตัวเลข ก็ต้องมีคุณสมบัติบวกลบคูณหารเลขเหมือนกัน
- หากจะเปรียบเทียบกันด้วย
<
,>
ได้ ก็ต้องมีคุณสมบัติPartialOrd
ที่ใช้สำหรับเปรียบเทียบเหมือนกัน
(เดี๋ยวเราอธิบายเพิ่มเติมในหัวข้อ Standard library ของ trait อีกที)
รวมถึง สามารถกำหนดให้ Generic ต้อง implement หลาย Traits ได้โดยใช้ +
ได้เช่นกัน
Note
T: Greet + Farewell
กำหนดว่าT
ต้อง implement ทั้งGreet
และFarewell
Default & Overload
Trait สามารถกำหนด Default Implementation ให้กับ method ได้ ซึ่ง Generic Type ที่ implement Trait นั้นสามารถใช้ได้ทันที
รวมถึงการ override เช่นเดียวกัน หากต้องการ override การทำงานของ say_hello
ใน Trait Greet
สำหรับ Robot
เพื่อให้มีพฤติกรรมเฉพาะ เราสามารถ implement method say_hello
ใหม่ในโครงสร้าง Robot
ได้ เช่น code ตามด้านล่างนี้
Note
say_hello
ในRobot
ถูก override ด้วยข้อความเฉพาะของRobot
แทนที่ Default Implementation ที่มีการใส่ไว้ใน trait ตอนแรกสุด
Associated Types
Associated Types ใน Rust เป็นคุณสมบัติของ Trait ที่ช่วยให้คุณสามารถกำหนดประเภท (type) ที่เกี่ยวข้องกับ Trait ได้ ช่วยลดความซับซ้อนในการใช้งาน Generic โดยไม่ต้องระบุประเภทอย่างชัดเจนทุกครั้งที่ใช้งาน Trait
คำถามคือ “ทำไมต้องใช้ Associated Types”
- ช่วยลดความยุ่งยากในการระบุ Generic Type หลายตัว
- ทำให้ code อ่านง่ายขึ้น โดยการกำหนดประเภทที่เกี่ยวข้องกับ Trait ภายใน Trait เอง
- ใช้ในสถานการณ์ที่ Trait ต้องการอธิบายประเภทเฉพาะที่ implement ต้องใช้
โดยวิธีการใช้งานคือ ใช้คำสั่ง type
ภายใน Trait เพื่อกำหนด Associated Type ขึ้นมา เช่น code นี้
type Item
คือ Associated Type ที่กำหนดว่าแต่ละประเภทที่ implement Trait นี้ต้องระบุว่าประเภทของItem
คืออะไรSelf::Item
ใช้เพื่ออ้างถึง Associated Type ที่ถูกกำหนดโดย implement นั้น ๆ
ทีนี้ในจังหวะ implement เมื่อประเภทใด ๆ implement Trait ที่มี Associated Type “ต้องกำหนดประเภทของ Associated Type ใน type
ด้วย”
ผลลัพธ์
Note
Counter
implement TraitIterator
โดยกำหนดtype Item = u32
ซึ่งหมายถึงค่าที่next
คืนกลับมาจะเป็นu32
- Method
next
ใช้ Associated TypeSelf::Item
แทนu32
โดยตรง ทำให้ code ยืดหยุ่น
ซึ่งถ้าเราลองสังเกตดูดีๆ “อันนี้มันทรงเดียวกับ Generic เลยนี่นา” (กำหนด type ก่อนจะเริ่มใช้งาน) เช่น code ชุดด้านบนนี้ หากเขียนเป็น Generic ก็จะได้ code ประมาณนี้ออกมา
คำถามคือ “แล้ว 2 อย่างนี้แตกต่างกันอย่างไร ?” หากเราลองเปรียบเทียบคุณสมบัติของแต่ละฝั่งดู
- Generic
- ต้องระบุประเภท (
T
,U
, ฯลฯ) ในขณะที่ใช้ Trait - ทุกครั้งที่ใช้ Trait หรือ implement Trait ต้องกำหนดประเภท Generic ให้ชัดเจน
- ยืดหยุ่นกว่า เพราะสามารถกำหนดประเภทได้หลากหลายเมื่อใช้งาน Trait ในที่ต่าง ๆ ได้
- เหมาะสำหรับสถานการณ์ที่ต้องการความยืดหยุ่นสูงหรือสามารถเปลี่ยนประเภทได้ในแต่ละครั้งที่ใช้
- ต้องระบุประเภท (
- Associated Types
- กำหนดประเภทที่เกี่ยวข้องภายใน Trait โดยตรง
- ประเภทนี้ถูก “ผูกติด” กับ Trait เมื่อถูก implement ทำให้ไม่ต้องระบุประเภทใหม่ทุกครั้งที่ใช้งาน ซึ่ง ก็จะมีข้อดีในการลดความยุ่งยากในการกำหนดประเภทซ้ำ ๆ เพราะประเภทถูก “ผูก” กับ Trait ตอน implement แล้วเรียบร้อย
- เหมาะสำหรับสถานการณ์ที่ Trait มีประเภทเฉพาะที่ไม่เปลี่ยนแปลงในแต่ละ implement
ดังนั้น จากการเปรียบเทียบ เราจะได้ข้อสรุปว่า
- ใช้ Generic: เมื่อคุณต้องการความยืดหยุ่นสูง และประเภทที่ใช้กับ Trait อาจแตกต่างกันไปในแต่ละครั้งที่ใช้งาน
- ใช้ Associated Types: เมื่อ Trait และประเภทมีความสัมพันธ์ที่แน่นอน และต้องการลดความซับซ้อนของการระบุประเภทใน code ได้
รวมถึงหากมองในแง่ compiler ตามหลักการของ Monomorphization
- Generic สำหรับ Generic Type Parameter (
T
) compiler จะสร้าง code ที่แตกต่างกันสำหรับแต่ละประเภทที่ใช้ โดย หากมีประเภทจำนวนมากที่ถูกใช้งาน จะเพิ่มขนาดของ binary และอาจทำให้การ compile ช้าลงได้ - กลับกัน Associated Types ไม่เพิ่มจำนวน code ที่ต้องสร้างแบบ Monomorphized เพราะประเภทถูกกำหนดตายตัวในตอน implement Trait แล้ว
ดังนั้น ก็จะมีประเด็นนี้ด้วยที่อาจจะส่งผลต่อขนาด binary ได้เช่นกัน
Dynamic Dispatch
Dynamic Dispatch คือการกำหนดพฤติกรรมของ Trait แบบ dynamic ในเวลา runtime แทนที่จะกำหนดล่วงหน้าในเวลา compile (Static Dispatch) โดยใช้ Trait Object (dyn Trait
) เพื่อสร้างความยืดหยุ่นในการจัดการประเภทที่แตกต่างกันที่ implement Trait เดียวกันไว้
Dynamic Dispatch ใช้ vtable (Virtual Table) ซึ่งเป็นโครงสร้างข้อมูลที่เก็บรายการของ function ที่ประเภทที่ implement Trait นั้นรองรับ เมื่อมีการเรียกใช้ function ผ่าน Trait Object compiler จะค้นหาและเรียก function ที่เหมาะสมจาก vtable ในเวลา runtime ออกมาได้
ดังเช่นแบบนี้ (ตัวอย่างจาก https://www.lurklurk.org/effective-rust/generics.html)
เมื่อตอนเก็บลงใน memory ก็จะมีหน้าตาประมาณนี้
จากภาพนี้แสดงการทำงานของ Dynamic Dispatch และ vtable ใน Rust โดย
- โครงสร้าง
square
มีข้อมูล เช่นtop_left.x
,top_left.y
, และsize
ที่ถูกจัดเก็บในหน่วยความจำ - เมื่อเรียกใช้ function
draw
ผ่านdyn Trait
ระบบจะใช้ vtable (Virtual Table) ซึ่งเป็นตารางที่เก็บรายการ function ที่ประเภทSquare
ได้ implement ไว้ เช่นSquare::bounds()
ในเวลา runtime - ระบบจะค้นหาและเรียก function ที่เหมาะสมจาก vtable โดยอ้างอิงจากข้อมูลของประเภทจริง (
Square
) ช่วยให้สามารถเรียก function ที่แตกต่างกันได้แม้จะผ่าน Trait เดียวกัน
เพิ่มเติมตัวอย่างการใช้งานแบบ code เต็ม เช่น code นี้
&dyn Greet
คือ Trait Object ที่รองรับ Dynamic Dispatch- function
greet
สามารถรับค่าประเภทใดก็ได้ที่ implement TraitGreet
- ในเวลา runtime ระบบจะเรียก function ที่ตรงกับประเภทจริงของค่าที่ส่งมาได้ (
Robot
หรือHuman
)
ประโยชน์ของการใช้ Dynamic Dispatch
- Polymorphism: Dynamic Dispatch ช่วยให้สามารถจัดการกับหลายประเภทข้อมูลที่ implement Trait เดียวกันได้ โดยไม่ต้องทราบประเภทจริงในเวลา compile เช่น การสร้าง Trait Object (
dyn Trait
) ช่วยให้ function เดียวรองรับหลายประเภทได้ เช่นdraw
สามารถใช้กับทั้งCircle
และSquare
- Binary Size: Dynamic Dispatch ไม่ต้องสร้าง code หลายชุดผ่านกระบวนการ Monomorphization เหมือน Static Dispatch ทำให้ขนาดไฟล์ binary เล็กลง โดยเฉพาะเมื่อมีการใช้งานประเภทที่หลากหลาย
- Dynamic: ในโครงสร้างข้อมูลที่เก็บวัตถุหลายประเภท เช่น
Vec<Box<dyn Trait>>
เราสามารถเก็บประเภทที่แตกต่างกันซึ่ง implement Trait เดียวกันได้ โดยไม่ต้องใช้ Generic หรือระบุขนาดที่แน่นอนล่วงหน้า
ข้อควรระวังในการใช้ Dynamic Dispatch
- Overhead ที่เพิ่มขึ้น: Dynamic Dispatch มีค่า Overhead เพิ่มจากการค้นหาและเรียก function ผ่าน vtable
- Trait Object (
dyn Trait
) ไม่รองรับการใช้งานร่วมกับ Generic หรือ Associated Types
Standard library
นอกเหนือจากการสร้าง Trait ทั่วไปแล้ว Rust ยังมี Standard Library ที่ช่วยในเรื่อง Trait Bound ได้ โดยมี Trait สำเร็จรูปที่ใช้กันอย่างแพร่หลาย เช่น Copy
, Clone
, Debug
, PartialEq
, Ord
, และ Hash
ซึ่งช่วยในการกำหนดพฤติกรรมพื้นฐานสำหรับประเภทต่าง ๆ และสามารถใช้ Trait Bound เหล่านี้ร่วมกับ Generic เพื่อเพิ่มความปลอดภัยและความยืดหยุ่นของ code
ตัวอย่างการใช้ Trait Bound จาก Standard Library ที่มักจะเจอได้บ่อยๆ
- ใช้
Debug
เพื่อพิมพ์ค่า โดยใช้ TraitDebug
ใช้สำหรับแสดงค่าของประเภทในรูปแบบ Debug (ผ่าน{:?}
)
- ใช้
Clone
เพื่อทำสำเนา โดยใช้ TraitClone
ช่วยให้สามารถสร้างสำเนาของค่าที่มีประเภทT
- ใช้
PartialEq
เพื่อเปรียบเทียบค่า โดย TraitPartialEq
ใช้ในการเปรียบเทียบความเท่า (==
) ระหว่างสองค่าที่มีประเภทเดียวกัน
- ใช้
Ord
เพื่อเรียงลำดับ โดย TraitOrd
ช่วยจัดการการเรียงลำดับค่าต่าง ๆ เช่น การเปรียบเทียบด้วย<
หรือ>
- ใช้
Hash
เพื่อสร้างค่า Hash โดย TraitHash
ใช้ในการสร้างค่า Hash สำหรับประเภทที่กำหนด
- ใช้
Add
เพื่อกำหนดว่าT
ต้อง implement TraitAdd
และผลลัพธ์ของการบวก (Output
) ต้องเป็นประเภทเดียวกันกับT
ข้อดีของการใช้ Standard library กับ Trait
- Code Reusability: Standard Library มี Trait ที่เตรียมไว้แล้ว เช่น
Clone
,Copy
,Debug
,PartialEq
, และOrd
ซึ่งครอบคลุมพฤติกรรมพื้นฐานที่พบได้บ่อย การใช้ Trait เหล่านี้ช่วยลดความจำเป็นในการสร้าง Trait ใหม่ที่ทำหน้าที่เหมือนกันได้ - Type Safety: Standard Library Trait ช่วยให้มั่นใจได้ว่าประเภทข้อมูลที่ใช้ต้องรองรับพฤติกรรมที่ต้องการ เช่น การเปรียบเทียบ (
PartialEq
) หรือการเรียงลำดับ (Ord
) โดย compiler จะตรวจสอบเงื่อนไขเหล่านี้ในเวลา compile ได้ - Flexibility: Trait ใน Standard Library เช่น
Add
,Mul
, และIterator
ช่วยให้ function หรือโครงสร้างข้อมูลทำงานได้กับประเภทที่หลากหลายโดยใช้ Trait Bound ได้ - ใช้ร่วมกับตัวอื่นๆใช้ Standard library ได้: โครงสร้างข้อมูลและ function ใน Standard Library เช่น
Vec
,HashMap
, และIterator
ออกแบบมาเพื่อทำงานร่วมกับ Trait เหล่านี้โดยตรง ทำให้การพัฒนา software ง่ายขึ้น เช่น การใช้ TraitIterator
ในการประมวลผลข้อมูล ดัง code ด้านล่างนี้
Smart Pointer
https://doc.rust-lang.org/book/ch15-00-smart-pointers.html
ก่อนที่เราจะพูดถึง Smart Pointer กัน ขอเล่าเรื่อง Pointer ที่เป็นรากฐานสำคัญของ low level programming กันก่อน
Pointer คือ ตัวแปรที่เก็บที่อยู่หน่วยความจำ (Memory Address) ของตัวแปรหรือข้อมูลอื่นในโปรแกรม โดย Pointer ใช้ในการอ้างอิงข้อมูลที่อยู่ในตำแหน่งความจำต่างๆ ซึ่งช่วยให้การทำงานใน low level programming สะดวกและมีประสิทธิภาพมากขึ้น
โดย Pointer จะมีบทความสำคัญคือ
- การจัดการหน่วยความจำแบบ dynamic (Dynamic Memory Management)
- การส่งค่าผ่าน Reference (Pass by Reference)
- การทำงานกับโครงสร้างข้อมูล เช่น Array, Linked List, Tree
- การเข้าถึงฮาร์ดแวร์ใน low level
โดย หากมองไปที่ภาษา C++ ที่เป็น low level programming ที่ developer มักใช้กันเมื่อต้องทำงานร่วมกับ hardware เราก็จะเจอประเด็นของ Pointer ว่า
- C++ ใช้ Pointer แบบดั้งเดิม (
int* ptr
) ที่สามารถชี้ไปยังตำแหน่งหน่วยความจำใดก็ได้ - developer ต้องจัดการหน่วยความจำเอง เช่น การจอง (
new
) และการคืนหน่วยความจำ (delete
) - ไม่มีระบบป้องกันความผิดพลาด เช่น Null Pointer Dereference (การเข้าถึง Pointer ที่ชี้ไปยังตำแหน่ง null) อาจทำให้เกิดข้อผิดพลาดร้ายแรงขึ้นได้ (Segmentation Fault)
- ทีนี้เพื่อป้องกันปัญหาเรื่องนี้ C++ ก็เลยได้มี library Smart Pointer เตรียมไว้ให้ โดยมีการเพิ่ม
std::shared_ptr
และstd::unique_ptr
เพื่อช่วยจัดการหน่วยความจำอัตโนมัติเอาไว้เช่นกัน แต่ยังคงต้องระมัดระวังการใช้งานร่วมกันใน code ที่ไม่ได้ใช้ Smart pointer ด้วยเช่นกัน
ทีนี้ มองมาที่ Rust บ้าง
- Rust ไม่มี Pointer แบบดั้งเดิม แต่ใช้แนวคิด Ownership, Borrowing, และ Lifetimes เพื่อควบคุมหน่วยความจำแทน
- Rust มีการป้องกันข้อผิดพลาด เช่น Null Pointer หรือ Dangling Pointer (Pointer ที่อ้างอิงหน่วยความจำที่ถูกคืนไปแล้ว) เอาไว้ในระดับ compiler ของ Rust เองแล้วเรียบร้อย โดย design ภาษาจะไม่อนุญาตให้ code ที่ไม่ปลอดภัยทำงานได้
- รวมถึง Rust เองก็มี Smart Pointer เช่น
Box
,Rc
, และRefCell
สำหรับการจัดการหน่วยความจำด้วยเช่นกัน (ซึ่งจะเป็นประเด็นที่เราจะเขียนถึงต่อ) - แต่ทั้งนี้ Rust เองก็ยังมี Raw Pointer (
const T
และmut T
) ซึ่งคล้าย Pointer แบบดั้งเดิมใน C++ แต่ควรต้องใช้งานในส่วน code ที่ไม่ปลอดภัย (unsafe
block) มากกว่า
ทีนี้เมื่อเราลองเทียบ 2 ภาษาอย่าง C++ กับ Rust ดู เราจะค้นพบว่า สิ่งหนึ่ง 2 ภาษานี้มีเหมือนกัน นั่นคือ Smart pointer คำถามก็เลยเกิดขึ้นมาว่า “ถ้างั้น Smart Pointer คืออะไร และทำไมต้องมีเพิ่มเข้ามาทั้งๆที่เราจัดการหน่วยความจำเองก็ได้”
Smart Pointer คืออะไร
Smart Pointer คือโครงสร้างข้อมูลที่ทำหน้าที่เหมือน Pointer ทั่วไป (เก็บที่อยู่ของหน่วยความจำ) แต่มีคุณสมบัติเพิ่มเติมที่ช่วยจัดการหน่วยความจำอัตโนมัติ เช่น การคืนหน่วยความจำเมื่อไม่ใช้งาน (Automatic Memory Management) โดยใช้เทคนิคอย่าง Reference Counting หรือ RAII (Resource Acquisition Is Initialization) ทำให้ code มีความปลอดภัยและลดโอกาสเกิดข้อผิดพลาด เช่น Memory Leak หรือ Dangling Pointer ที่จะเกิดขึ้นได้
แม้ว่าเราจะจัดการหน่วยความจำเองได้ แต่การจัดการหน่วยความจำด้วยตนเองมักมีความเสี่ยงและข้อผิดพลาดที่อาจเกิดขึ้นได้บ่อย เช่น
- Memory Leak: หากลืมคืนหน่วยความจำที่จองไว้ จะทำให้เกิด Memory Leak ส่งผลให้หน่วยความจำไม่เพียงพอเมื่อโปรแกรมทำงานไปนานๆ
- Dangling Pointer: Pointer ที่ชี้ไปยังหน่วยความจำที่ถูกคืนแล้ว หากใช้งาน Pointer นั้นอีกจะเกิดข้อผิดพลาดร้ายแรง เช่น Segmentation Fault
- Null Pointer Dereference: การเข้าถึง Pointer ที่เป็น
null
จะทำให้โปรแกรม crash ได้
ซึ่งอย่างที่เราเห็น เพื่อตัดปัญหาการจัดการ Memory ด้วย pointer ใน Rust จึงตัดสินใจไม่มี Pointer แบบดั้งเดิม แต่เลือกให้ทุกอย่างจัดการผ่าน Smart Pointer แทน
แต่การเลือกใช้งาน Smart Pointer ใน Rust ไม่ได้หมายความว่าเราสูญเสียความยืดหยุ่นในการจัดการ Memory ไปนะครับ แต่กลับทำให้โปรแกรมมีความปลอดภัยมากขึ้นในเชิงหน่วยความจำ ด้วยระบบ Ownership และ Borrowing ที่ทำงานควบคู่กัน
Smart Pointer ใน Rust ช่วยให้เราจัดการหน่วยความจำได้อย่างมีประสิทธิภาพ โดยไม่ต้องกังวลเรื่องปัญหาเช่น Null Pointer หรือ Dangling Pointer ที่พบในภาษาดั้งเดิม ทั้งยังรองรับการใช้งาน Raw Pointer สำหรับกรณีที่ต้องการประสิทธิภาพสูงสุด ทำให้ Rust กลายเป็นภาษาที่สมดุลระหว่างความยืดหยุ่นและความปลอดภัยของโปรแกรมได้อย่างลงตัวอีกหนึ่งภาษาเช่นกัน
ทีนี้ ถึงจุดนี้หลายคนก็อาจจะสงสัยเพิ่มเติมว่า “ถ้าอย่างนั้น use case อะไรบ้างที่ควรใช้ Smart Pointer ในการทำ Software” เพื่อให้เกิดความเข้าใจที่ถ่องแท้ขึ้น เราต้องอธิบาย Memory ในการเก็บข้อมูลกันว่าจริงๆแล้วข้อมูลถูกจัดเก็บใน memory อย่างไร
Ref: https://open4tech.com/memory-layout-embedded-c-programs/
โดยทั่วไปแล้ว หน่วยความจำของโปรแกรมจะถูกแบ่งออกเป็นหลายส่วนที่มีหน้าที่แตกต่างกัน ได้แก่ Stack, Heap, Data Segment และ Code Segment ซึ่งแต่ละส่วนมีบทบาทเฉพาะตัวในการจัดเก็บข้อมูลที่จำเป็นสำหรับการทำงานของโปรแกรม
- Stack เป็นพื้นที่สำหรับเก็บตัวแปรแบบ local และข้อมูลที่เกี่ยวข้องกับ function เช่น parameter และตัวแปรที่ประกาศใน function โดยมีการเรียกใช้งานแบบ LIFO (Last In, First Out) ซึ่งมีความเร็วสูงในการจัดการ แต่มีขนาดจำกัด
- Heap ใช้สำหรับเก็บข้อมูลแบบ dynamic memory allocation ซึ่ง developer สามารถจัดสรรและคืนค่าหน่วยความจำใน runtime ได้ มีความยืดหยุ่นสูงแต่ต้องระวังการจัดการหน่วยความจำไม่ให้เกิดปัญหา เช่น memory leak
- Data Segment แบ่งออกเป็นส่วนของข้อมูลที่ถูกกำหนดค่าไว้ล่วงหน้า (Initialized Data) และข้อมูลที่ยังไม่ได้กำหนดค่า (Uninitialized Data หรือ BSS Segment) ใช้เก็บตัวแปร global และ static
- Code Segment (หรือ Text Segment) ใช้สำหรับเก็บคำสั่งที่โปรแกรมต้องดำเนินการ เป็นส่วนที่ไม่สามารถเขียนทับได้ใน runtime เพื่อความปลอดภัย
เท่ากับว่า ในเคสของการเขียน code ทั่วไป ข้อมูลตัวแปรทั้งหมดที่เราใช้ จะถูกเก็บอยู่ใน Stack เอาไว้ ในกรณีของการเขียน code ทั่วไปใน Rust ข้อมูลตัวแปรส่วนใหญ่ที่มีขนาดคงที่และทราบขนาดในเวลา compile time จะถูกเก็บไว้ใน Stack เนื่องจาก Stack มีความเร็วในการเข้าถึงสูงและจัดการง่ายผ่านรูปแบบ LIFO
ตัวอย่าง เช่น code นี้
ในตัวอย่างนี้
x
เป็นตัวแปรชนิด integer (i32
โดยค่าเริ่มต้น) ซึ่ง Rust จะเก็บค่าของมันไว้ใน Stacky
เป็น string slice (&str
) ซึ่ง pointer และ metadata (length) ของ string จะถูกเก็บใน Stack แต่ข้อมูลตัว string จริง ๆ (“Hello, Rust!”) จะเก็บไว้ในส่วนอื่น เช่น.rodata
(read-only data segment)
อย่างไรก็ตาม ตัวแปรที่เก็บบน Stack จะถูกจัดการโดยอัตโนมัติผ่านระบบ ownership และ scope ของ Rust โดยสำหรับ Rust ระบบจะจัดการหน่วยความจำของ Stack ให้โดยอัตโนมัติ ตัวแปรจะถูกสร้างและลบออกจาก Stack เมื่อออกนอก scope โดยไม่ต้องเขียน code เพื่อจัดการเอง ทำให้การจัดการหน่วยความจำมีความปลอดภัยและลดโอกาสเกิดปัญหาหน่วยความจำ เช่น dangling pointers หรือ memory corruption ไว้ได้
ทีนี้แล้ว Heap ละ จะโดนเก็บไว้ในเคสไหน
ใน Rust ข้อมูลจะถูกจัดเก็บใน Heap เมื่อเราต้องการการจัดสรรหน่วยความจำแบบ dynamic หรือเมื่อข้อมูลมีขนาด “ไม่สามารถทราบได้ในเวลา compile time” การจัดเก็บใน Heap มีความยืดหยุ่น แต่การเข้าถึงจะช้ากว่า Stack เนื่องจากต้องอาศัย pointer เพื่อเข้าถึงข้อมูล
ตัวอย่างการใช้ Heap ใน Rust
จาก code
- **
Box::new(42)
**ใช้Box<T>
ซึ่งเป็น Smart Pointer สำหรับการจัดเก็บข้อมูล42
บน Heap โดย pointer จะเก็บไว้บน Stack เพื่อชี้ไปยังตำแหน่งใน Heap ที่เก็บค่าจริง vec![1, 2, 3, 4, 5]
เมื่อสร้างVec<T>
(vector) Rust จะจัดเก็บ metadata เช่น ความยาวและ pointer บน Stack แต่เนื้อหาของ vector (เช่น ตัวเลข1-5
) จะถูกจัดเก็บบน Heap
ดังนั้นจากเรื่อง memory ทั้ง 2 เรื่องนี้
- เราจะใช้ Stack เมื่อข้อมูลของเรามีขนาดคงที่ (fixed size) และทราบขนาดได้ในเวลา compile time เช่นตัวแปรพื้นฐาน (primitive types) หรือ references โดย Stack เหมาะสำหรับการเก็บข้อมูลชั่วคราวที่มี lifetime สั้นและต้องการความเร็วสูงในการเข้าถึง รวมถึงข้อมูลที่สามารถลบออกได้โดยอัตโนมัติเมื่อออกนอก scope
- เราจะใช้ Heap เมื่อข้อมูลของเรามีขนาดไม่แน่นอนหรือใหญ่เกินกว่าจะเก็บใน Stack เช่นการสร้างโครงสร้างข้อมูลที่ต้องการ dynamic memory allocation เช่น vectors หรือ
Box<T>
ซึ่งช่วยให้เราสามารถจัดการหน่วยความจำได้อย่างยืดหยุ่นและรองรับการใช้งานในระยะยาว โดยต้องอาศัย pointer และระบบ ownership ของ Rust เพื่อควบคุมการเข้าถึงและการจัดการหน่วยความจำอย่างปลอดภัย
ทีนี้เรากลับมาสู่ Smart Pointer บ้าง แล้ว Smart pointer เกี่ยวข้องอย่างไรกับเรื่อง Stack และ Heap
Ref: https://ezesunday.com/blog/choosing-between-str-and-string-in-rust/
Smart Pointer ใน Rust มีความเกี่ยวข้องกับ Stack และ Heap ในการจัดการหน่วยความจำ โดยทำหน้าที่เป็นตัวกลางที่ช่วยจัดการการอ้างอิงและการปลดปล่อยหน่วยความจำอย่างปลอดภัย โดยจะใช้ทั้งหน่วยความจำส่วน Stack และ Heap ทั้งคู่
- ใช้ Stack ทำอะไร ?
- Pointer และ Metadata บน Stack: Smart Pointer เช่น
Box<T>
หรือRc<T>
จะเก็บ pointer และ metadata ที่เกี่ยวข้อง (เช่น reference count สำหรับRc<T>
) ไว้บน Stack เพื่อชี้ไปยังข้อมูลจริงที่อยู่ใน Heap - การจัดการ Lifetime: ระบบ Smart Pointer ใช้ scope ของ Stack เพื่อควบคุมการสร้างและการทำลายข้อมูลใน Heap ตัวอย่างเช่น เมื่อ Smart Pointer ออกจาก scope Rust จะเรียก drop เพื่อคืนค่าหน่วยความจำใน Heap
- Pointer และ Metadata บน Stack: Smart Pointer เช่น
- ใช้ Heap ทำอะไร ?
- การจัดเก็บข้อมูลจริงใน Heap: Smart Pointer จะจัดเก็บข้อมูลจริงใน Heap เมื่อข้อมูลมีขนาดไม่แน่นอนหรือไม่สามารถเก็บใน Stack ได้ เช่น
Box<T>
ใช้เก็บข้อมูลใน Heap โดยตรง และRc<T>
หรือArc<T>
ช่วยให้แชร์ข้อมูลใน Heap ได้ระหว่างหลายส่วนของโปรแกรม - Dynamic Memory Management: Smart Pointer ช่วยจัดการหน่วยความจำใน Heap เช่น การเพิ่มหรือลด reference count (
Rc<T>
และArc<T>
), การเข้าถึงข้อมูลแบบ mutable (RefCell<T>
), และการประมวลผล concurrent (Mutex<T>
)
- การจัดเก็บข้อมูลจริงใน Heap: Smart Pointer จะจัดเก็บข้อมูลจริงใน Heap เมื่อข้อมูลมีขนาดไม่แน่นอนหรือไม่สามารถเก็บใน Stack ได้ เช่น
ดังนั้น ถ้าเราจะสรุปภายใน 1 ประโยคคือ Smart Pointer คือตัวที่ทำหน้าที่จัดการการเชื่อมโยงระหว่าง Stack (สำหรับ metadata และ pointer) และ Heap (สำหรับข้อมูลจริง)
โดยสิ่งนี้ยังคง concept ของ Ownership ไว้ได้ แม้จะ dynamic ที่ runtime เพราะ Smart Pointer ใน Rust ถูกออกแบบมาให้ปฏิบัติตามระบบ Ownership ผ่านการควบคุมหน่วยความจำด้วยวิธีที่ปลอดภัย เช่น
- การเป็นเจ้าของข้อมูลใน Heap โดยสมบูรณ์ (เช่น
Box<T>
) - การแบ่งปัน ownership อย่างชัดเจนผ่าน reference counting (เช่น
Rc<T>
และArc<T>
) - การจัดการการเปลี่ยนแปลงข้อมูลแบบ interior mutability (เช่น
RefCell<T>
หรือMutex<T>
)
โดย Smart Pointer จะรับประกันว่าเมื่อเจ้าของสุดท้ายของข้อมูลออกนอก scope “หน่วยความจำจะถูกคืนค่าให้ระบบโดยอัตโนมัติ” ซึ่งช่วยรักษาความปลอดภัยและหลีกเลี่ยงปัญหาหน่วยความจำ เช่น dangling pointer หรือ memory leak แม้การจัดสรรหน่วยความจำจะเกิดขึ้นแบบ dynamic ใน runtime ขึ้นมาก็ตาม
เพื่อให้เราเห็นภาพมากขึ้น เราจะมาทำความรู้จัก Smart Pointer ตัวเด่นทั้ง 3 ตัวก่อนคือ Box<T>
, Rc<T>
, RefCell<T>
ตามที่เรายกตัวอย่างไปกัน
Box
Box<T>
คือ smart pointer ที่ใช้สำหรับจัดการหน่วยความจำใน heap โดยเฉพาะในกรณีที่ต้องการเก็บข้อมูลที่ขนาดไม่แน่นอนหรืออ้างถึงข้อมูลที่ต้องการอายุการใช้งานยาวขึ้น แต่ยังคงปลอดภัยในการใช้งานในระบบของ Rust ที่มี ownership และ borrowing เป็นพื้นฐาน
คุณสมบัติเด่นๆของ Box<T>
Box
เป็น immutable โดยค่าเริ่มต้น แต่สามารถเปลี่ยนเป็น mutable ได้ถ้าต้องการ- เมื่อ
Box
ถูก drop หน่วยความจำที่จัดสรรไว้บน heap จะถูกคืนค่าโดยอัตโนมัติ
โดย Box<T>
จะช่วยในการ
- จัดการหน่วยความจำบน heap
- ทำงานร่วมกับชนิดข้อมูลที่มีขนาดไม่ทราบล่วงหน้า เช่น recursive types
ตัวอย่างการใช้งาน
- ในตัวอย่างนี้ ค่า
10
ถูกจัดเก็บไว้บน heap และx
เป็นตัวชี้ (smart pointer
) ไปยังตำแหน่งนั้น
หรือตัวอย่างกับการใช้กับ Recursive Type
ในตัวอย่างนี้
Box
ช่วยให้สามารถกำหนด recursive type (List
) ได้โดยไม่ก่อให้เกิดปัญหาในเรื่องขนาดที่ไม่สิ้นสุดระหว่าง compile-time ได้- โดย
Box
จะช่วยลดภาระของระบบหน่วยความจำโดยใช้ heap แทน stack
อีกตัวอย่าง ที่จะเปลี่ยน Box เป็น mutable
- ใช้ตัวดำเนินการ dereference (*) เพื่อเข้าถึงค่าภายใน
Box<T>
และเปลี่ยนเป็นค่าที่ต้องการ หลังจากเปลี่ยนค่าBox<T>
จะเก็บค่าที่อัปเดตใหม่เข้าไป
แต่ๆ การใช้ Box<T>
แบบ mutable เพื่อเปลี่ยนค่าภายในเป็นสิ่งที่ ไม่ใช่เรื่องที่ทำบ่อย ใน Rust เนื่องจากมี concept อื่นที่สามารถแก้ไขแบบ mutable ได้ และ concept ตรงไปตรงมามากกว่า (เช่น Vec<T>
หรือ RefCell<T>
)
Rc
Rc<T>
คือ reference-counted smart pointer ที่ช่วยให้สามารถแชร์ ownership ของค่าบน heap ระหว่างตัวแปรหลายตัวได้ โดยจะนับจำนวน reference ที่ชี้ไปยังค่าดังกล่าว และเมื่อไม่มี reference ใดเหลืออยู่ ระบบจะปลดปล่อยหน่วยความจำบน heap โดยอัตโนมัติ
โดยคุณสมบัติเด่นๆของ Rc<T>
คือ
- ใช้สำหรับสถานการณ์ที่ต้องแชร์ข้อมูลเดียวกันระหว่างหลายตัวแปรใน thread เดียว
- ทำงานในลักษณะ immutable shared ownership (ค่าภายใน
Rc<T>
เปลี่ยนแปลงไม่ได้โดยตรง)
ตัวอย่างการใช้งาน แชร์ค่าระหว่างหลายตัวแปร
ผลลัพธ์
จาก code
value
ถูกแชร์ระหว่างตัวแปรa
และb
- ใช้
Rc::clone
เพื่อเพิ่ม reference count โดยไม่สร้าง copy ใหม่ - จำนวน reference (
Rc::strong_count
) แสดงว่าvalue
ถูกแชร์ 3 ตัว (รวมvalue
,a
, และb
)
หรือ อีกตัวอย่าง ใช้ Rc<T>
กับโครงสร้างข้อมูลที่เป็น Graph
ก็จะเป็นอีก concept หนึ่งที่ใช้สำหรับ share ownership ระหว่างกันได้ (จากแต่เดิมตามระบบ ownership ที่จะ strict ให้มี owner เพียงแค่ทีละ 1 เท่านั้น)
คำถามคือ “เมื่อไหร่ควรใช้ Rc<T>
?”
- เมื่อมีข้อมูลที่ต้องการแชร์แบบ immutable ระหว่างหลายส่วนของโปรแกรม
- เมื่อไม่สามารถใช้
Box<T>
หรือVec<T>
ได้เพราะต้องการแชร์ ownership
ทีนี้หลายคนก็อาจจะสงสัย concept RC มันเหมือนกับเวลาเราอ้างอิงไปยัง Address ของ pointer เลย (ที่สามารถ Borrow มากกว่า 1 ตัวได้จากเรื่อง Ownership) แล้ว concept RC ดีกว่าการ Borrow ปกติอย่างไร ?
เหตุผลที่ Rc
ดีกว่าการ Borrow ปกติก็คือ มันช่วยให้เราสามารถแชร์ ownership ของค่าเดียวกันได้อย่างปลอดภัยและยืดหยุ่นยิ่งขึ้น ในขณะที่ Borrow ปกติใช้ระบบ lifetime constraints ที่บังคับให้ reference ต้องมีอายุการใช้งานไม่เกิน owner ตัวต้นทาง ซึ่งอาจทำให้การแชร์ข้อมูลระหว่างหลายส่วนของโปรแกรมซับซ้อนหรือเป็นไปไม่ได้ในบางกรณี
แต่ Rc
ใช้ระบบ reference counting ในการจัดการอายุการใช้งานของค่า โดยที่เราสามารถ clone ตัว Rc
เพื่อสร้างการอ้างอิงใหม่ได้โดยไม่ต้องกังวลเรื่อง lifetime ของ owner ตัวเดิม อีกทั้ง Rc
จะปลดปล่อยหน่วยความจำให้โดยอัตโนมัติเมื่อไม่มี reference ใดเหลืออยู่ ซึ่งช่วยลดความยุ่งยากในการจัดการหน่วยความจำด้วยตัวเอง ทำให้เหมาะกับสถานการณ์ที่ต้องการแชร์ข้อมูล immutable ระหว่างหลายส่วนของโปรแกรมอย่างปลอดภัยและมีประสิทธิภาพมากขึ้น
พูดง่ายๆ ก็คือ ค่าใน Rc
จะยังคงมีชีวิตอยู่บน heap ตราบใดที่ยังมี reference (หรือ owner) ที่ชี้ไปยังมันอยู่ เมื่อ reference สุดท้ายถูก drop (หรือ “ตาย”) หน่วยความจำที่จัดเก็บค่านั้นจะถูกปลดปล่อยทันทีโดยอัตโนมัติ ระบบนี้ทำให้ Rc
เป็นตัวเลือกที่ดีในสถานการณ์ที่เราต้องการแชร์ข้อมูลเดียวกันระหว่างหลายจุดในโปรแกรม โดยไม่ต้องกังวลว่าค่านั้นจะถูกลบก่อนที่ทุก reference จะเลิกใช้งานไปพร้อมกัน นั่นแหละข้อดีของ reference counting
รวมถึงสิ่งที่ต้องระวังในการใช้ Rc<T>
- ไม่เหมาะกับ multi-threading: หากต้องแชร์ข้อมูลระหว่าง thread ให้ใช้
Arc<T>
(Atomic Reference Counted) (เดี๋ยวเราจะอธิบายในหัวข้อ thread อีกที) - Circular reference: ระวังการสร้างวงจรอ้างอิง (circular reference) ที่จะทำให้หน่วยความจำไม่ถูกปลดปล่อย เช่น กรณี Graph หรือ Tree ที่มีการอ้างกลับไปมา ควรใช้
Weak<T>
ร่วมด้วยเพื่อลดปัญหานี้
RefCell
RefCell<T>
เป็น smart pointer ที่ช่วยให้สามารถทำ interior mutability ได้ หรือพูดง่ายๆ คือ ช่วยให้เราสามารถเปลี่ยนแปลงค่าภายในได้ แม้ว่า RefCell<T>
จะอยู่ในบริบทที่เป็น immutable ก็ตาม ทั้งนี้ การเปลี่ยนแปลงค่าดังกล่าวจะถูกตรวจสอบใน runtime ไม่ใช่ compile-time (เหมือน Box<T>
หรือ Rc<T>
)
คุณสมบัติเด่นของ RefCell<T>
- Interior Mutability: สามารถเปลี่ยนค่าภายใน
RefCell<T>
ได้ แม้ตัวแปรนั้นจะเป็น immutable - Borrow Checking ใน Runtime: ระบบจะตรวจสอบกฎ borrow ที่ runtime โดย
- เราสามารถ borrow ค่าแบบ immutable (
borrow()
) ได้หลายครั้ง - แต่สามารถ borrow ค่าแบบ mutable (
borrow_mut()
) ได้เพียงครั้งเดียว
- เราสามารถ borrow ค่าแบบ immutable (
- เหมาะกับโครงสร้างข้อมูลที่ต้องการแชร์หรือเปลี่ยนค่าในสถานการณ์ที่ปกติไม่ได้รับอนุญาต เช่น
Rc<RefCell<T>>
ตัวอย่างการใช้งานอย่างง่าย
ผลลัพธ์
ต่อมา ดูตัวอย่างการใช้งานร่วมกับ Rc<T>
บ้าง สำหรับแชร์ค่าที่เปลี่ยนแปลงได้
ผลลัพธ์
สังเกตว่า
- RefCell ถูกสร้างขึ้นมาเสร็จแล้วถูกห่อด้วย Rc เพื่อทำการส่งต่อ reference ไปให้กับตัวอื่นๆ
- เมื่อตัวใดตัวหนึ่งมีการ
borrow_mut()
ไปเปลี่ยนค่า ตัวที่ reference อยู่ทั้งหมดก็จะโดนเปลี่ยนตาม
คำถามเช่นเดิม “เมื่อไหร่ควรใช้ RefCell<T>
?”
- เมื่อเราต้องการเปลี่ยนค่าภายในโครงสร้างข้อมูลที่ immutable
- เมื่อใช้งานร่วมกับ smart pointer อื่นๆ เช่น
Rc<T>
เพื่อแชร์ข้อมูลที่ต้องการเปลี่ยนแปลง - เมื่อต้องการหลีกเลี่ยงข้อจำกัดของ borrow checker ที่ compile-time แต่ยังต้องการความปลอดภัยใน runtime
ข้อควรระวัง
- Borrow Mutations ใน Runtime: หากมีการ borrow mutable พร้อมกับ borrow immutable ในเวลาเดียวกัน จะเกิด panic ใน runtime
- ค่า Overhead ใน Runtime: การตรวจสอบ borrow เกิดขึ้นใน runtime ซึ่งอาจมี overhead ในบางกรณี
- ไม่เหมาะกับ multi-threading: หากต้องการแชร์ใน multi-thread ให้ใช้
Mutex<T>
หรือRwLock<T>
แทน (เดี๋ยวอธิบายในเรื่อง Thread เพิ่มเติม)
Weak
Weak<T>
ใน Rust เป็น smart pointer ที่ใช้งานร่วมกับ Rc<T>
เพื่อป้องกันปัญหา วงจรอ้างอิง (cyclic reference) ซึ่งอาจทำให้หน่วยความจำไม่ถูกปลดปล่อย (memory leak) โดย Weak<T>
จะสร้าง reference ไปยังค่าใน Rc<T>
โดยไม่เพิ่ม reference count
(strong count) ของค่าใน Rc<T>
นั่นเอง
คุณสมบัติเด่นๆของ Weak<T>
- ไม่มี Strong Ownership:
Weak<T>
ไม่ได้เป็น owner ของค่า แต่สามารถอ้างอิงถึงค่าในRc<T>
ได้ - ไม่เพิ่ม Strong Count: การใช้
Weak<T>
ไม่เพิ่มจำนวน reference count ของRc<T>
ช่วยป้องกันปัญหา cyclic reference - Upgrade: สามารถอัปเกรด
Weak<T>
เป็นRc<T>
ด้วยวิธี.upgrade()
เพื่อใช้งานค่าที่ชี้ไป หากค่าถูกปลดปล่อยไปแล้ว.upgrade()
จะคืนค่าNone
ตัวอย่าง เคสที่เราไม่ได้ใช้ Weak ของกรณีที่มี linked list ชี้ไปยัง next และ prev
ปัญหาของ code นี้คือ
node1
และnode2
อ้างอิงซึ่งกันและกัน ทำให้ reference count ของทั้งสองตัวไม่ลดลงถึง 0 = ทำให้เกิดปัญหา Memory Leak ได้- ก็จะส่งผลทำให้แม้ว่าจะออกจาก
main()
หน่วยความจำของnode1
และnode2
จะยังคงถูกครอบครอง เนื่องจาก reference count ยังไม่เป็น 0 จึงทำให้ไม่เกิดการ free memory เกิดขึ้น
วิธีแก้ปัญหาด้วย Weak<T>
Note
- เปลี่ยน type ของ prev เป็น
Option<Weak<RefCell<Node>>>
- ใช้
Rc::downgrade()
แทนRc::clone()
เมื่อต้องการสร้าง weak reference
ตัวอย่างเพิ่มเติมเรื่องคำสั่ง upgrade()
ผลลัพธ์
ในตัวอย่างนี้
parent
ใช้Weak<T>
สำหรับอ้างอิง Node แม่ในchild
เพื่อหลีกเลี่ยงวงจรอ้างอิง- การใช้
.upgrade()
กับWeak<T>
จะคืนค่าSome(Rc<Node>)
หาก Node แม่ยังมีชีวิตอยู่ และNone
หากถูก drop ไปแล้ว
ข้อดีของ Weak<T>
- ป้องกันปัญหา cyclic reference ที่อาจเกิดกับ
Rc<T>
- ลด overhead ในการจัดการ reference count
ข้อควรระวัง
- การใช้
.upgrade()
กับWeak<T>
ที่อ้างถึงค่าที่ถูก drop แล้วจะคืนค่าNone
- ใช้เฉพาะในสถานการณ์ที่ต้องการอ้างอิงชั่วคราวหรือหลีกเลี่ยง cyclic reference
โดยปกติ Weak<T>
จะเหมาะสำหรับโครงสร้างข้อมูลแบบกราฟหรือ tree ที่มีความสัมพันธ์ซับซ้อน เช่น parent-child และช่วยให้การจัดการหน่วยความจำปลอดภัยยิ่งขึ้นได้
Lifetime
Lifetime คือแนวคิดที่ใช้สำหรับจัดการอายุการใช้งานของข้อมูล (หรือ reference) เพื่อให้มั่นใจว่า reference ที่ใช้งานใน code จะ ปลอดภัย และไม่มีการอ้างถึงข้อมูลที่หมดอายุ (dangling reference) โดย Rust ใช้ borrow checker เพื่อตรวจสอบและบังคับใช้นโยบายเกี่ยวกับ Lifetime ระหว่างการ compile
คำถามคือ “ทำไม Rust ต้องมี Lifetime?” อย่างที่เราทราบกันว่า ใน Rust การจัดการหน่วยความจำนั้น ปราศจาก garbage collector และขึ้นอยู่กับ ownership และ borrowing
- Borrowing: เราสามารถยืม reference แบบ immutable หรือ mutable ได้
- หาก reference หนึ่งชี้ไปยังข้อมูลที่ถูกปลดปล่อย (dropped) จะเกิดข้อผิดพลาด เช่น dangling reference ขึ้นมาได้
Lifetime จึงถูกใช้เพื่อบอกว่า reference ใดสามารถใช้งานได้ในช่วงเวลาไหน และ Rust จะตรวจสอบว่า reference มีอายุการใช้งานที่สอดคล้องกับข้อมูลที่มันชี้ไปหรือไม่ออกมาได้
ตัวอย่างง่ายๆเช่น
- ในระบบ Lifetime ของ Rust ก็จะส่งผลทำให้ x, r หมดอายุพร้อมกันได้
Custom Lifetime
Custom Lifetime เป็น concept ที่สำคัญในการจัดการหน่วยความจำและการอ้างอิงข้อมูล โดยมีวัตถุประสงค์หลักเพื่อให้ compiler ตรวจสอบความปลอดภัยของการใช้งานการอ้างอิง (references) ใน code
Rust ใช้ lifetime เพื่อประกันว่าการอ้างอิงจะไม่ชี้ไปยังข้อมูลที่ถูกทำลายไปแล้ว รวมถึง ช่วยป้องกันปัญหาการเข้าถึงหน่วยความจำที่ไม่ถูกต้อง (memory safety) ด้วยเช่นกัน
ตัวอย่าง code
Note
- จาก code นี้ใช้ lifetime parameter
'a
เพื่อบอก compiler ว่าการอ้างอิงทั้งสองมีอายุการใช้งานเท่ากัน - คืนค่าเป็น reference ที่มี lifetime เดียวกับ input
เพื่อความเข้าใจที่เพิ่มขึ้น เราจะมาทำความรู้จักกับ 'a
กัน
'a
เป็น Lifetime Parameter ที่มีความสำคัญ โดยเป็นสัญลักษณ์ที่ใช้ระบุอายุการใช้งานของการอ้างอิง (reference) เพื่อใช้บอก compiler ว่าการอ้างอิงนั้นๆ จะมีชีวิตอยู่นานเท่าไหร่ โดยสามารถใช้ได้กับทั้ง Function, Return values, Struct, Trait
- จริงๆแล้ว สามารถตั้งชื่ออื่นได้ แต่โดยทั่วไปจะนิยมใช้ตัวอักษรอังกฤษพิมพ์เล็กเรียงไปเลย เช่นใช้
'a
,'b
,'c
- รวมถึง สามารถใช้มากกว่าหนึ่ง lifetime parameter ได้ ในกรณีที่ lifetime ของ parameter อาจจะไม่เท่ากัน
ดังเช่นตัวอย่างนี้
Note
- function
longest_and_first
มี 2 lifetime parameter: ‘a และ ‘b - Struct
Excerpt
มี 2 lifetime parameter เช่นกัน
การมี lifetime parameter สองอันมีประโยชน์อย่างมากในสถานการณ์ที่ต้องจัดการกับการอ้างอิงที่มีอายุการใช้งานแตกต่างกันหรือต้องการควบคุมการเข้าถึงข้อมูล เช่น
- การจัดการความสัมพันธ์ระหว่างข้อมูลในระบบฐานข้อมูล ที่ซึ่งคุณต้องเก็บการอ้างอิงระหว่าง record หลักและ record ย่อยที่มีวงจรชีวิตไม่เหมือนกัน
- ในการสร้างโครงสร้างข้อมูลที่ซับซ้อน เช่น linked list หรือ tree การใช้พารามิเตอร์ lifetime สองตัวช่วยให้เราจัดการความสัมพันธ์ระหว่างโหนดต่างๆ ได้อย่างมีประสิทธิภาพมากขึ้นได้
Lifetime elision rules
ที่นี่บางคนก็อาจจะสงสัยเล็กน้อย “แล้วถ้าไม่กำหนด lifetime parameter เลยใน code compiler จะจัดการเรื่องนี้อย่างไร ?”
ใน Rust หากเราไม่กำหนด lifetime parameter เองใน code คอมไพเลอร์ Rust จะพยายามใช้สิ่งที่เรียกว่า lifetime elision rules เพื่ออนุมาน lifetimes ให้เราโดยอัตโนมัติ กฎเหล่านี้เป็นชุดของ heuristic ที่ค่อนข้างง่าย ซึ่งครอบคลุมกรณีการใช้งานทั่วไปส่วนใหญ่ ทำให้เราเขียน code ได้กระชับยิ่งขึ้น โดยไม่ต้องระบุ lifetimes อย่างชัดเจนทุกครั้ง
กฎ lifetime elision มีดังนี้
- Each parameter that is a reference gets its own lifetime parameter = parameter แต่ละตัวที่เป็น reference จะได้รับ lifetime parameter ของตัวเอง
- If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters = ถ้ามี input lifetime parameter เพียงตัวเดียว lifetime นั้นจะถูกกำหนดให้กับ output lifetime parameter ทั้งหมด
- If there are multiple input lifetime parameters, but one of them is
&self
or&mut self
, the lifetime ofself
is assigned to all output lifetime parameters = ถ้ามี input lifetime parameter หลายตัว แต่มีหนึ่งในนั้นคือ&self
หรือ&mut self
lifetime ของself
จะถูกกำหนดให้กับ output lifetime parameter ทั้งหมด
เรามาลองดูแต่ละตัวอย่างเพิ่มเติมกัน
ตัวอย่าง 1: function ที่มี reference เพียงตัวเดียว
ในตัวอย่างนี้ เราไม่ได้กำหนด lifetime parameter ใดๆ แต่ compiler จะอนุมานให้เราโดยอัตโนมัติ ตามกฎข้อ 1
s: &str
กลายเป็นs: &'a str
โดยที่'a
เป็น lifetime ใหม่ที่คอมไพเลอร์สร้างขึ้น
ตัวอย่าง 2: function ที่รับ reference สองตัวและคืนค่า reference
ในตัวอย่างนี้ เราได้กำหนด lifetime parameter 'a
อย่างชัดเจน เนื่องจากเราต้องการบอก compiler ว่า lifetime ของ input (x
และ y
) และ output ต้องสัมพันธ์กัน (output ต้องมีชีวิตอยู่ตราบเท่า input ตัวใดตัวหนึ่ง) ถ้าเราไม่กำหนด compilerจะไม่สามารถรู้ได้ว่าจะเลือก lifetime ใด
เช่น ถ้าเราพยายามเขียนแบบนี้โดยไม่ใส่ lifetime
compiler จะแจ้ง error E0106: missing lifetime specifier
เพราะไม่สามารถอนุมาน lifetime ได้ เนื่องจากมี input lifetime มากกว่าหนึ่งตัว และไม่มี self
ตัวอย่าง 3: Method ที่มี self
ตามกฎข้อ 3 compiler จะอนุมาน lifetime ของ output เป็น lifetime ของ self
นั่นคือ 'a
ดังนั้นจริงๆ แล้ว method นี้มีความหมายเหมือนกับ code ชุดด้านล่างนี้
ดังนั้น กฎ lifetime elision ช่วยลด boilerplate และทำให้ code อ่านง่ายขึ้นในกรณีส่วนใหญ่ แต่อย่างไรก็ตาม เมื่อกฎเหล่านี้ไม่เพียงพอ เช่น เมื่อมีความสัมพันธ์ที่ซับซ้อนระหว่าง lifetimes ของ input และ output เราจำเป็นต้องกำหนด lifetime parameter อย่างชัดเจน เพื่อให้ compiler เข้าใจความตั้งใจของเราและตรวจสอบความถูกต้องของ code ได้ (ซึ่งเอาจริงๆ compiler ก็จะบังคับให้เราเติมให้ครบอยู่ดี ตาม error ที่เราเห็นด้านบน)
Static Lifetime
นอกจากนี้ ยังมีเรื่อง Static Lifetime ที่เป็น lifetime ที่พิเศษที่สุด หมายถึง ข้อมูลนั้นมีชีวิตอยู่ “ตลอดระยะเวลา” ของโปรแกรม หรือตั้งแต่โปรแกรมเริ่มต้นจนกระทั่งสิ้นสุดลง
เราสามารถระบุ static lifetime ได้โดยใช้ 'static
เช่น &'static str
หรือ &'static i32
การอ้างอิงที่มี static lifetime นั้นรับประกันได้ว่าจะสามารถเข้าถึงได้ตลอดเวลา ทำให้มั่นใจได้ว่าไม่มีปัญหา dangling pointer หรือการเข้าถึงหน่วยความจำที่ถูก deallocate ไปแล้ว
จุดเด่นๆของ Static Lifetime
- มีชีวิตอยู่ตลอดโปรแกรม: ข้อมูลที่มี static lifetime จะถูกเก็บไว้ในส่วนของข้อมูลของโปรแกรม (data segment) ซึ่งจะโหลดเข้าสู่หน่วยความจำเมื่อโปรแกรมเริ่มต้น และจะคงอยู่จนกระทั่งโปรแกรมสิ้นสุด
- ใช้กับ string literals: string literals ใน Rust เช่น
"Hello, world!"
มี static lifetime โดยอัตโนมัติ นั่นเป็นเพราะว่า string literals ถูกเก็บไว้ในส่วนของข้อมูลของโปรแกรม - ใช้กับ global constants: ค่าคงที่ global ที่ประกาศด้วย
static
จะมี static lifetime เช่นกัน - ใช้กับข้อมูลที่ถูก embed ใน binary: ข้อมูลที่ถูก compile เข้าไปใน binary ของโปรแกรม เช่น รูปภาพหรือข้อมูล configuration บางอย่าง มักจะมี static lifetime ไปด้วยเลย
ตัวอย่าง code
เมื่อไหร่ที่ควรใช้ Static Lifetime
- String literals: ใช้กับ string literals ได้โดยตรง
- ค่าคงที่ global: ใช้กับค่าคงที่ที่ต้องการให้เข้าถึงได้จากทุกส่วนของโปรแกรม
- ข้อมูลที่ embed ใน binary: ใช้กับข้อมูลที่ถูกฝังอยู่ในโปรแกรม เช่น ข้อมูล configuration หรือ asset ต่างๆ
แต่ก็จะมีจุดที่ควรระวังคือ
- การใช้งานมากเกินไปอาจทำให้เกิด memory leak: เนื่องจากข้อมูลที่มี static lifetime จะไม่ถูก deallocate จนกว่าโปรแกรมจะสิ้นสุด การใช้งาน static lifetime กับข้อมูลที่ไม่จำเป็นต้องมีชีวิตอยู่ตลอดโปรแกรม อาจทำให้เกิด memory leak ได้
- ไม่สามารถใช้กับข้อมูลที่สร้างขึ้นแบบ runtime ได้: ข้อมูลที่ถูกสร้างขึ้นในขณะ runtime เช่น ข้อมูลที่อ่านจากไฟล์ หรือข้อมูลที่ได้รับจากผู้ใช้ ไม่สามารถมี static lifetime ได้โดยตรง เพราะ static lifetime หมายถึงข้อมูลที่ถูกกำหนดไว้ตั้งแต่ compile time
- การใช้
&'static
กับข้อมูลที่ไม่ static ต้องใช้Box::leak
(ควรหลีกเลี่ยง): ถ้าต้องการบังคับให้ข้อมูลที่ไม่ static มี static lifetime จะต้องใช้Box::leak
ซึ่งจะทำให้หน่วยความจำของข้อมูลนั้นรั่วไหล (leak) และไม่ควรใช้วิธีนี้หากไม่จำเป็นจริงๆ เช่น
Static lifetime ควรใช้อย่างระมัดระวังและเฉพาะเมื่อจำเป็นจริงๆ เท่านั้น โดยทั่วไปแล้ว ควรหลีกเลี่ยงการใช้ static lifetime กับข้อมูลที่ถูกสร้างขึ้นในขณะ runtime หรือข้อมูลที่มีอายุการใช้งานจำกัด เพราะอาจนำไปสู่ปัญหา memory leak ได้ การใช้งานที่เหมาะสมคือเมื่อต้องการข้อมูลที่คงอยู่ตลอดระยะเวลาของโปรแกรม เช่น string literals, global constants, ข้อมูล configuration ที่ฝังอยู่ใน binary หรือข้อมูลที่จำเป็นต้องเข้าถึงจากทุกส่วนของโปรแกรม หากไม่แน่ใจว่าข้อมูลจำเป็นต้องมีชีวิตอยู่นานขนาดนั้นหรือไม่ ควรพิจารณา lifetimes อื่นๆ หรือ ownership system ของ Rust เพื่อจัดการหน่วยความจำอย่างมีประสิทธิภาพมากกว่า การพยายามบังคับให้ข้อมูลมี static lifetime ด้วย Box::leak
ควรเป็นทางเลือกสุดท้ายและควรหลีกเลี่ยงหากทำได้ เนื่องจากวิธีนี้จะทำให้เกิด memory leak อย่างแน่นอน
Concurrency
https://doc.rust-lang.org/book/ch16-00-concurrency.html
Concurrency หมายถึงความสามารถในการเขียนโปรแกรมที่สามารถดำเนินงานหลายส่วนได้ในเวลาเดียวกัน ซึ่ง Rust ให้ความสำคัญกับ ความปลอดภัย และ ประสิทธิภาพ ผ่านระบบ ownership, type system, และ compile-time checks เพื่อป้องกันปัญหาที่มักเกิดขึ้นในโปรแกรม concurrent เช่น data races, deadlocks, และ dangling pointers โดยไม่ต้องพึ่งพา garbage collector
Ref: https://unicminds.com/program-vs-process-vs-thread/
โดย Rust มีเครื่องมือหลายอย่างสำหรับการจัดการ concurrency ให้ปลอดภัยได้ ตั้งแต่
- Threads: Rust ใช้ OS threads เพื่อรันงานหลายอย่างพร้อมกัน โดยการสร้าง threads ใน Rust ผ่านการใช้
std::thread::spawn
(และ Ownership ของข้อมูลก็ยังคงถูกควบคุมอย่างเข้มงวดเพื่อป้องกันปัญหาด้าน memory safety ด้วยเช่นกัน) - Message Passing: Rust สนับสนุนแนวคิด message passing โดยใช้ channels (
std::sync::mpsc
) เพื่อส่งข้อความระหว่าง threads ได้ - Shared State (Mutex & Arc): การแชร์ state ระหว่าง threads อย่างปลอดภัยผ่าน
Mutex<T>
(เพื่อป้องกันการแก้ไขข้อมูลพร้อมกัน) หรือArc<T>
(สำหรับแชร์ ownership ของข้อมูลระหว่าง threads แบบ immutable)
ซึ่งนอกเหนือจาก concurrency แบบ threads แบบดั้งเดิม Rust ยังรองรับ asynchronous programming อีกด้วย
Asynchronous Programming คือการเขียนโปรแกรมที่ช่วยให้การดำเนินงานบางส่วนของโปรแกรมสามารถ รอ หรือ พักการทำงาน (await) โดยไม่ต้องบล็อกการทำงานของส่วนอื่น ๆ ของโปรแกรม เพื่อใช้ทรัพยากรของระบบให้มีประสิทธิภาพสูงสุด โดยเฉพาะในงานที่เกี่ยวข้องกับ I/O เช่น การอ่านเขียนไฟล์, การส่งรับข้อมูลจากเครือข่าย หรือการทำงานแบบ concurrent
โดย Rust รองรับ asynchronous programming ผ่าน async
/await
syntax และ future-based system ที่ช่วยให้การจัดการ concurrency แบบ non-blocking ง่ายขึ้นด้วยเช่นกัน (เดี๋ยวเราจะกลับมาอธิบายเพิ่มเติมใน asynchronous อีกที)
ซึ่งนั่นเท่ากับว่า Rust support การเขียนโปรแกรม concurrency ทั้งในรูปแบบ Thread และ Asynchronous ที่ถือว่าครอบคลุม และยืดหยุ่นต่อการใช้งานในหลากหลายบริบทด้วยเช่นกัน เราจะค่อยๆมาเรียนรู้ผ่านแต่ละคำสั่งในการจัดการ Concurrency กัน
การสร้าง Thread เบื้องต้น
การสร้าง Thread สามารถทำได้ด้วย function std::thread::spawn
ซึ่งเป็นวิธีพื้นฐานสำหรับการทำงานแบบ concurrent โดยแต่ละ thread จะทำงานแยกจาก thread หลัก (main thread)
code ตัวอย่าง
ผลลัพธ์
Note
- ใช้ function
thread::spawn
เพื่อสร้าง thread ใหม่ โดย code ภายในthread::spawn
จะถูกเรียกใน thread ใหม่ขึ้นมา
ขออธิบายบรรทัด handle.join().unwrap()
เพิ่ม
handle.join().unwrap()
เป็นคำสั่งที่ใช้ในภาษา Rust กับ thread เพื่อรอให้ thread นั้นทำงานเสร็จสิ้น และรับค่าที่ thread ส่งกลับมา โดย
- เมื่อเราสร้าง thread ด้วย
thread::spawn()
function นี้จะส่งกลับค่าJoinHandle
ซึ่งเรามักจะเก็บไว้ในตัวแปร เช่นhandle
ตัวJoinHandle
นี้เป็นตัวแทนของ thread ที่สร้างขึ้น และใช้สำหรับจัดการ thread นั้น เช่น รอให้มันเสร็จสิ้น - method
join()
บนJoinHandle
มีหน้าที่หลักคือรอให้ thread ที่เกี่ยวข้องทำงานเสร็จสิ้น เมื่อเรียกhandle.join()
thread ปัจจุบัน (thread ที่เรียกjoin()
) จะหยุดรอจนกว่า thread ที่handle
อ้างถึงจะทำงานเสร็จสิ้น - สุดท้าย
join()
ส่งกลับค่าResult<T, E>
ซึ่งT
คือค่าที่ thread ส่งกลับ (ถ้ามี) และE
คือ error ที่อาจเกิดขึ้นระหว่างการ join (เช่น thread panic)unwrap()
เป็นวิธีในการจัดการกับResult
- ส่วน
unwrap()
นั้นสามารถใช้งานเพื่อแกะค่าResult
ออกมาได้ แต่ควรใช้ด้วยความระมัดระวัง เพราะถ้า thread panic โปรแกรมจะหยุดทำงานทันที
ใน production code ควรใช้การจัดการ error ที่ดีกว่า เช่น การใช้ match ลักษณะนี้ (โดย handle จากตัว Result
ที่คืนมา)
คำถามต่อมา “แล้วถ้าจะส่งค่าตัวแปรจากภายนอกไปใช้ใน Thread ละทำได้อย่างไร” = คำตอบคือ ต้องทำการย้าย ownership ผ่าน move closure เข้าไปนั่นเอง
move closure คือ closure ที่ ย้าย ownership ของตัวแปรที่มัน capture มายังตัวมันเอง แทนที่จะ ยืม การใช้งาน move closure มีประโยชน์อย่างมากเมื่อคุณต้องการส่ง closure ไปยัง thread อื่น เพราะ thread อื่นนั้นมี stack และ lifetime ที่แยกจาก thread ปัจจุบัน การย้าย ownership จึงเป็นการรับประกันว่าข้อมูลที่ closure ต้องการใช้งานนั้นยังคงมีอยู่และ valid ใน thread ใหม่
คำถามคือ “ทำไมต้อง move closure เมื่อส่งไปยัง Thread?”
โดยทั่วไปแล้ว closure ใน Rust จะ ยืม ค่าจาก scope ที่มันถูกสร้างขึ้นมา ซึ่งหมายความว่า closure จะเก็บการอ้างอิง (reference) ไปยังตัวแปรเหล่านั้น ปัญหาคือเมื่อเราส่ง closure ไปยัง thread อื่น thread นั้นอาจมีอายุการใช้งานที่แตกต่างจาก thread ปัจจุบัน ตัวแปรที่ closure ยืมมาอาจถูก drop ไปแล้วใน thread เดิม ทำให้เกิด dangling reference ใน thread ใหม่
move closure แก้ปัญหานี้โดยการ ย้าย ownership ของตัวแปรที่ capture มายังตัว closure เอง ทำให้ closure เป็นเจ้าของข้อมูลนั้นโดยสมบูรณ์ ไม่มีการอ้างอิงใดๆ ที่อาจกลายเป็น dangling reference อีกต่อไป
เช่น code ตัวอย่างนี้ เป็นการส่ง move closure ไปยัง thread ทำได้ง่ายๆ โดยใช้ std::thread::spawn
ในตัวอย่างนี้ name
ถูกย้ายไปยัง closure ที่ถูกส่งไปยัง thread ใหม่ ด้วย move ||
ทำให้ thread ใหม่เป็นเจ้าของ name
และสามารถใช้งานได้โดยไม่มีปัญหาได้
Smart pointer กับ Thread
ใน Rust Smart pointer ที่ใช้ใน context ของ thread จะมีลักษณะพิเศษคือต้องรองรับการใช้งานร่วมกัน (shared ownership) และจัดการการเข้าถึงข้อมูลพร้อมกัน (concurrent access) อย่างปลอดภัย ซึ่งแตกต่างจาก smart pointer ทั่วไปที่เน้นการจัดการ ownership แบบเดี่ยว (single ownership) หรือการยืม (borrowing) เท่านั้น
Smart pointer หลักๆ ที่ใช้กับ Thread ใน Rust ได้แก่
Arc
(Atomic Reference Counter) อนุญาตให้มี ownership ของข้อมูลT
ร่วมกันได้หลาย owner โดยใช้ atomic reference counting ในการติดตามจำนวน owner ที่ยังคงใช้งานข้อมูลอยู่ เมื่อ reference count กลายเป็น 0 ข้อมูลก็จะถูก deallocate โดยอัตโนมัติ (ใช้สำหรับเคส read only)Mutex
(Mutual Exclusion) ให้การเข้าถึงข้อมูลT
แบบ exclusive โดยอนุญาตให้ thread เพียง thread เดียวเข้าถึงข้อมูลได้ในเวลาใดเวลาหนึ่ง เพื่อป้องกัน data race (ใช้สำหรับเคสที่มีการแก้ข้อมูลหลาย thread และมักใช้ร่วมกับArc<T>
)RwLock
(Read-Write Lock) อนุญาตให้มี read access พร้อมกันได้หลาย thread แต่จำกัด write access ให้มีเพียง thread เดียวในเวลาใดเวลาหนึ่ง เหมาะสำหรับกรณีที่ read operation มีมากกว่า write operation เพื่อเพิ่มประสิทธิภาพ (มักใช้ร่วมกับArc<T>)
คำถามคือ Smart Pointer เหล่านี้ แตกต่างกับ Smart Pointer ในชุดก่อนหน้าอย่างไร คำตอบของเรื่องนี้คือ trait Send
กับ Sync
ซึ่งเป็น trait สำคัญใน Rust ที่เกี่ยวข้องกับการเขียนโปรแกรมแบบ concurrent หรือการทำงานแบบ multi-threaded
trait Send
บ่งบอกว่า type ใด type หนึ่งนั้น ปลอดภัยที่จะส่ง ไปยัง thread อื่นได้ นั่นหมายความว่า ownership ของข้อมูลนั้นสามารถโอนย้ายจาก thread หนึ่ง ไปยังอีก thread หนึ่งได้อย่างปลอดภัย โดยไม่ก่อให้เกิด data race หรือปัญหาอื่นๆ ที่เกี่ยวกับการเข้าถึงข้อมูลพร้อมๆ กันจากหลาย threads
ส่วน trait Sync
บ่งบอกว่า type ใด type หนึ่งนั้น ปลอดภัยที่จะแชร์ reference แบบ immutable ระหว่างหลาย threads นั่นหมายความว่า หลายๆ threads สามารถเข้าถึงข้อมูลพร้อมๆ กันได้ โดยที่ไม่มีการเปลี่ยนแปลงข้อมูล (immutable access)
ถ้าสรุปแบบสั้นๆคือ
Send
เกี่ยวข้องกับการ โอนย้าย ownership ระหว่าง threadsSync
เกี่ยวข้องกับการ แชร์ immutable reference ระหว่าง threads
เช่นตัวอย่างจาก Smart Pointer แบบเปรียบเทียบกัน
Arc<T>
implement ทั้งSend
และSync
ทำให้สามารถแชร์ข้อมูลระหว่าง threads ได้อย่างปลอดภัย โดยมีการจัดการ reference count แบบ atomic เพื่อป้องกัน data raceRc<T>
ไม่ implementSend
และSync
ทำให้ไม่สามารถแชร์ระหว่าง threads ได้อย่างปลอดภัย เนื่องจากใช้ reference count แบบ non-atomic ซึ่งอาจเกิด data race ได้ล
เราจะลองดูตัวอย่าง Smart Pointer ของแต่ละตัวที่ implement ในเรื่อง thread กัน
Arc
Arc<T>
หรือ Atomic Reference Counting เป็น smart pointer ใน Rust ที่ช่วยให้เราจัดการ ownership ของข้อมูล T
ร่วมกันในหลาย threads ได้อย่างปลอดภัย หลักการทำงานของ Arc<T>
คือการนับจำนวนการอ้างอิง (reference count) ไปยังข้อมูล เมื่อการอ้างอิงสุดท้ายหมดไป ข้อมูลก็จะถูก deallocate โดยอัตโนมัติ ทำให้ป้องกันปัญหา memory leak และ dangling pointer ในสภาพแวดล้อมแบบ concurrent ได้
ทีนี้ถ้าเราอ่านดู คุณสมบัติมันก็จะเหมือนๆกับ Rc<T>
เลย คำถามคือ “มันมีความแตกต่างระหว่าง Rc<T>
และ Arc<T>
กันอย่างไรละ**”**
Rc<T>
(Reference Counting) ก็เป็น smart pointer ที่ใช้จัดการ ownership ร่วมกันเช่นกัน แต่Rc<T>
ไม่ปลอดภัย ในการใช้งานกับหลาย threads เพราะการเพิ่ม/ลด reference count ไม่ใช่ atomic ซึ่งอาจเกิด data race ได้หากมีหลาย threads แก้ไขค่า reference count พร้อมกันได้Arc<T>
แก้ปัญหานี้โดยใช้ atomic operations ในการจัดการ reference count ทำให้การเพิ่ม/ลดค่าเป็นไปอย่างปลอดภัยในสภาพแวดล้อมแบบ concurrent ได้
อธิบายเพิ่มเติม เรื่อง Atomic operations นะครับ
Atomic operations คือการดำเนินการกับข้อมูลที่รับประกันว่าจะทำงานเสร็จสมบูรณ์ใน “ขั้นตอนเดียว” โดยไม่มีการถูกขัดจังหวะโดยการดำเนินการอื่นใด ไม่ว่าจะเป็นจาก thread อื่น หรือ interrupt ใดๆก็ตาม ในบริบทของ multithreading การดำเนินการแบบ atomic มีความสำคัญอย่างยิ่งในการป้องกัน data races และรักษาความสอดคล้องของข้อมูลเอาไว้ได้
โดยปัญหา Data race นั้นเกิดขึ้นเมื่อ thread สอง thread หรือมากกว่าเข้าถึงและแก้ไขข้อมูลเดียวกันพร้อมกัน โดยที่อย่างน้อยหนึ่งในการเข้าถึงนั้นเป็นการแก้ไข การดำเนินการแบบ atomic ช่วยป้องกัน data races โดยรับประกันว่าการเข้าถึงและแก้ไขข้อมูลจะเป็นไปอย่าง exclusive คือ thread ใด thread หนึ่งจะเข้าถึงข้อมูลได้เพียง thread เดียวในเวลาใดเวลาหนึ่งได้
เรามาดูตัวอย่างของ Arc<T>
กัน
ในตัวอย่างนี้ Arc::clone(&counter)
จะสร้าง Arc<T>
ใหม่ที่ชี้ไปยังข้อมูลเดียวกันและเพิ่มตัวนับอ้างอิง เมื่อ Thread ทำงานเสร็จและ Arc<T>
ใน Thread นั้นหมด scope ตัวนับอ้างอิงจะลดลง
Arc<T>
เป็นเครื่องมือสำคัญในการจัดการข้อมูลที่แชร์ใน Rust โดยเฉพาะอย่างยิ่งในสภาพแวดล้อมแบบ multithread ช่วยให้เขียน code concurrent ที่ปลอดภัยและมีประสิทธิภาพได้ง่ายขึ้น
Mutex
Mutex<T>
(Mutual Exclusion) ใน Rust คือเครื่องมือสำหรับการจัดการการเข้าถึงข้อมูลร่วมกันระหว่างหลาย Thread อย่างปลอดภัย เพื่อป้องกันปัญหา Data Race ซึ่งเกิดขึ้นเมื่อหลาย Thread พยายามเข้าถึงและแก้ไขข้อมูลเดียวกันพร้อมกัน
Mutex<T>
ทำงานโดยใช้หลักการของ “lock” กล่าวคือ ก่อนที่ Thread ใด Thread หนึ่งจะสามารถเข้าถึงข้อมูลภายใน Mutex<T>
ได้ Thread นั้นจะต้องทำการ “lock” mutex ก่อน ถ้ามี Thread อื่นถือ lock อยู่ Thread ที่พยายาม lock จะต้องรอจนกว่า lock จะถูกปล่อย (unlock) หลังจากใช้งานข้อมูลเสร็จ Thread จะต้องทำการ “unlock” mutex เพื่อให้ Thread อื่นสามารถเข้าถึงข้อมูลได้
เรามาดูตัวอย่าง code กัน
จาก code นี้
- เราใช้
Arc<Mutex<i32>>
เพื่อให้Mutex
สามารถแชร์ระหว่าง Thread ต่างๆ ได้ - แต่ละ Thread จะ lock
Mutex
ด้วยlock().unwrap()
ก่อนที่จะเพิ่มค่า counter เข้าไป - เมื่อ Thread เสร็จสิ้น lock จะถูกปล่อยโดยอัตโนมัติ
- เมื่อทุก Thread ทำงานเสร็จ เราจะเห็นว่า counter เพิ่มขึ้น 10 อย่างถูกต้อง
ข้อควรระวังของการใช้ Mutex
- Deadlock: เกิดขึ้นเมื่อ Thread สอง Thread หรือมากกว่ารอ lock ซึ่งกันและกัน ทำให้โปรแกรมหยุดทำงาน ตัวอย่างเช่น Thread A ถือ lock ของ mutex M1 และพยายาม lock mutex M2 ในขณะที่ Thread B ถือ lock ของ mutex M2 และพยายาม lock mutex M1
- Poisoning: เกิดขึ้นเมื่อ Thread ที่ถือ lock เกิด panic ข้อมูลภายใน mutex อาจอยู่ในสถานะที่ไม่ถูกต้อง Rust จะ mark mutex ว่า “poisoned” เพื่อเตือนให้ทราบถึงปัญหานี้
Mutex<T>
เป็นเครื่องมือสำคัญในการเขียนโปรแกรม concurrent ใน Rust ช่วยให้สามารถแชร์ข้อมูลระหว่าง Thread ได้อย่างปลอดภัยโดยป้องกัน data races อย่างไรก็ตาม ควรระวังเรื่อง deadlock และ poisoning เพื่อให้โปรแกรมทำงานได้อย่างเสถียรและไม่เกิดปัญหา error ตามมาได้
RwLock
RwLock<T>
(Read-Write Lock) ใน Rust เป็นเครื่องมือสำหรับการจัดการการเข้าถึงข้อมูลร่วมกันระหว่างหลาย Thread โดยมีกลไกที่ยืดหยุ่นกว่า Mutex<T>
ซึ่งอนุญาตให้มีผู้อ่านหลายคนพร้อมกัน แต่จำกัดให้มีผู้เขียนเพียงคนเดียวในแต่ละครั้ง
ความแตกต่างระหว่าง Mutex<T>
และ RwLock<T>
Mutex<T>
: อนุญาตให้ Thread เพียง Thread เดียวเข้าถึงข้อมูลได้ ไม่ว่าจะเป็นการอ่านหรือการเขียน เหมาะสำหรับกรณีที่มีการเขียนข้อมูลบ่อย หรือต้องการความปลอดภัยสูงสุด แม้กระทั่งการอ่านRwLock<T>
: แยกการเข้าถึงออกเป็นสองแบบ:- Read access (การอ่าน): อนุญาตให้หลาย Thread เข้าถึงข้อมูลเพื่ออ่านได้พร้อมกัน
- Write access (การเขียน): อนุญาตให้ Thread เพียง Thread เดียวเข้าถึงข้อมูลเพื่อเขียนได้
RwLock<T>
เหมาะสำหรับกรณีที่การอ่านข้อมูลเกิดขึ้นบ่อยกว่าการเขียน ซึ่งช่วยเพิ่มประสิทธิภาพการทำงานแบบ concurrent
RwLock<T>
มีหลักการทำงานคล้ายกับ Mutex<T>
แต่มีการจัดการที่ซับซ้อนกว่า เพื่อรองรับการอ่านพร้อมกันเพิ่มเติมเข้ามา
- Locking สำหรับการอ่าน (Read Lock): เมื่อ Thread ต้องการอ่านข้อมูล จะต้องขอ “read lock” หากไม่มี Thread ใดถือ “write lock” อยู่ Thread นั้นจะได้รับ “read lock” และสามารถอ่านข้อมูลได้ Thread อื่น ๆ ก็สามารถขอ “read lock” และอ่านข้อมูลพร้อมกันได้
- Locking สำหรับการเขียน (Write Lock): เมื่อ Thread ต้องการเขียนข้อมูล จะต้องขอ “write lock” หากไม่มี Thread ใดถือ “read lock” หรือ “write lock” อยู่ Thread นั้นจะได้รับ “write lock” และสามารถเขียนข้อมูลได้ ในขณะที่ Thread นั้นถือ “write lock” อยู่ Thread อื่น ๆ ไม่สามารถขอทั้ง “read lock” และ “write lock” ได้
ตัวอย่างการใช้งาน
ผลลัพธ์
จาก code
- เราใช้
Arc<RwLock<i32>>
เพื่อแชร์RwLock
ระหว่าง Thread - Thread ที่เป็นเลขคู่จะทำงานอ่านข้อมูล ในขณะที่ Thread ที่เป็นเลขคี่จะทำงานเขียนข้อมูล (ดังนั้นการเขียนข้อมูลควรจะเกิดขึ้นเพียง 2 ครั้ง)
RwLock
ช่วยให้การอ่านข้อมูลเกิดขึ้นพร้อมกันได้ โดยไม่เกิดปัญหา และรับประกันว่าการเขียนข้อมูลจะเกิดขึ้นแบบ exclusive
การพิจารณาระหว่าง Mutex<T>
และ RwLock<T>
Mutex<T>
: อนุญาตให้ Thread เดียวเท่านั้นเข้าถึงข้อมูล ไม่ว่าจะเป็นการอ่านหรือเขียน เหมาะสำหรับกรณีที่มีการเขียนข้อมูลบ่อยRwLock<T>
: อนุญาตให้ผู้อ่านหลายรายเข้าถึงข้อมูลพร้อมกัน แต่จำกัดให้มีผู้เขียนเพียงรายเดียว เหมาะสำหรับกรณีที่มีการอ่านข้อมูลบ่อยกว่าการเขียน
สิ่งที่ควรระวังเกี่ยวกับ RwLock<T>
- Deadlock: เช่นเดียวกับ
Mutex<T>
ควรระวัง deadlock โดยเฉพาะอย่างยิ่งเมื่อมีการใช้RwLock
หลายตัวร่วมกัน - Starvation : ในบางกรณี หากมีผู้อ่านจำนวนมากเข้ามาอย่างต่อเนื่อง อาจทำให้ผู้เขียนไม่ได้รับโอกาสในการเข้าถึงข้อมูลเลย เกิดภาวะ starvation ได้
- Poisoning: ถ้า Thread panic ในขณะที่ถือ write lock
RwLock
จะถูก poisoned คล้ายกับMutex
RwLock<T>
เป็นเครื่องมือที่มีประโยชน์ในการจัดการการเข้าถึงข้อมูลที่แชร์ โดยเฉพาะอย่างยิ่งในสถานการณ์ที่มีการอ่านข้อมูลบ่อยกว่าการเขียน ช่วยเพิ่มประสิทธิภาพการทำงานของโปรแกรม concurrent แต่ควรพิจารณาข้อควรระวังต่างๆ เช่น deadlock และ starvation เพื่อให้โปรแกรมทำงานได้อย่างถูกต้องและมีประสิทธิภาพ
ส่งข้อมูลระหว่าง Thread
การส่งข้อมูลระหว่าง Thread ใน Rust คือกระบวนการที่ Thread ต่างๆ ในโปรแกรมสามารถแบ่งปันและเข้าถึงข้อมูลร่วมกันได้ ซึ่งมีความสำคัญในการเขียนโปรแกรม Concurrent หรือโปรแกรมที่ทำงานพร้อมกัน เพื่อให้ Thread ต่างๆ สามารถทำงานร่วมกันได้อย่างมีประสิทธิภาพ
ใน Rust มีวิธีการหลายอย่างในการส่งข้อมูลระหว่าง Thread อย่างปลอดภัย โดยเน้นเรื่องการป้องกัน Data Race ซึ่งเป็นปัญหาสำคัญในการเขียนโปรแกรม Concurrent
วิธีการส่งข้อมูลระหว่าง Thread ใน Rust จะมีวิธีการส่งในแต่ละแบบได้ดังนี้
- Move Semantics
- ใน Rust การย้ายความเป็นเจ้าของ (Ownership) ของข้อมูลจาก Thread หนึ่งไปอีก Thread หนึ่งเป็นวิธีที่ง่ายที่สุดในการส่งข้อมูล
- เมื่อข้อมูลถูกย้ายไปที่ Thread ใหม่ Thread เดิมจะไม่สามารถเข้าถึงข้อมูลนั้นได้อีก ป้องกันปัญหา Data Race ได้อย่างมีประสิทธิภาพ
- ใช้
move
keyword เพื่อบังคับการย้าย Ownership เข้าไปยัง Closure ของ Thread
ดัง code ตัวอย่างนี้
- Shared Memory กับ Mutex
std::sync::Mutex
(Mutual Exclusion) ใช้สำหรับป้องกันการเข้าถึงข้อมูลพร้อมกันจากหลาย ThreadMutex
จะล็อคข้อมูล ทำให้มีเพียง Thread เดียวที่สามารถเข้าถึงข้อมูลได้ในขณะใดขณะหนึ่ง- ใช้
Arc
(Atomically Reference Counted) เพื่อให้สามารถแชร์ Ownership ของMutex
ระหว่าง Thread หลายตัว
ดัง code ตัวอย่างนี้
- Channels
ก่อนพูดถึง Channels เราขอพูดถึง MPSC ซึ่งเป็นรูปแบบการส่งที่ใช้ประโยชน์จาก Channels กันก่อน
Multiple Producer, Single Consumer (MPSC) คือ รูปแบบการสื่อสารระหว่าง Thread ที่อนุญาตให้มี Thread Producer หลาย Thread ส่งข้อมูลไปยัง Consumer Thread เพียง Thread เดียว
โดย Rust มีช่องทางการสื่อสารระหว่าง Thread ที่เรียกว่า Channel ซึ่งมี implementation แบบ MPSC ให้ใช้งานใน std::sync::mpsc
โดย Channel ประกอบด้วย 2 ส่วนคือ
- Sender: ใช้โดย Producer Thread เพื่อส่งข้อมูลไปยัง Channel
- Receiver: ใช้โดย Consumer Thread เพื่อรับข้อมูลจาก Channel
หลักการทำงานคือ Producer หลายรายสามารถสร้าง Sender clone ได้หลายอัน และส่งข้อมูลผ่าน Sender เหล่านั้นไปยัง Receiver ตัวเดียวกัน ซึ่งเป็นของ Consumer Thread เมื่อมีข้อมูลส่งเข้ามา Receiver จะรับข้อมูลตามลำดับที่ส่งเข้ามา (FIFO)
ข้อดีของการใช้ MPSC ใน Rust
- Rust ช่วยให้มั่นใจได้ว่าการส่งและรับข้อมูลผ่าน channel นั้นปลอดภัยต่อ Thread ป้องกัน data race ได้
- รวมถึง Channel จัดการเรื่องของ ownership ของข้อมูลที่ส่ง ทำให้ไม่ต้องกังวลเรื่องการจัดการ memory
ตัวอย่าง code การใช้ MPSC
- Atomic Types
Atomic Types คือ ประเภทข้อมูลพื้นฐานที่รองรับการทำงานแบบ atomic ซึ่งหมายถึงการดำเนินการกับข้อมูลประเภทนี้จะเกิดขึ้นอย่างสมบูรณ์ในขั้นตอนเดียว โดยไม่มีการแทรกแซงจาก Thread อื่น ๆ ในระหว่างการดำเนินการ ทำให้มั่นใจได้ว่าข้อมูลจะไม่เสียหายจากการเข้าถึงพร้อมกัน (data races) เหมาะสำหรับการใช้งานในสภาพแวดล้อมแบบ Multithread โดยเฉพาะ
std::sync::atomic
ใช้สำหรับตัวแปร Atomic ที่สามารถเข้าถึงและแก้ไขได้อย่างปลอดภัยจากหลาย Thread โดยไม่ต้องใช้ Lock- เหมาะสำหรับการนับเลข หรือ Flag ที่มีการแก้ไขบ่อยๆ
ดัง code ตัวอย่างนี้
Asynchronous
Asynchronous Programming คือรูปแบบการเขียนโปรแกรมที่ช่วยให้โปรแกรมสามารถทำงานหลายอย่างพร้อมกันได้ โดยไม่ต้องสร้าง Thread ใหม่ทุกครั้ง ซึ่งช่วยเพิ่มประสิทธิภาพและลดการใช้ทรัพยากรของระบบ โดยเฉพาะอย่างยิ่งในงานที่เกี่ยวข้องกับการรอ เช่น การอ่านข้อมูลจากเครือข่าย การอ่านไฟล์ หรือการรับข้อมูลจากผู้ใช้
ความแตกต่างระหว่าง Asynchronous และ Thread
- Thread: สร้าง Thread ใหม่สำหรับแต่ละ Task ทำให้ใช้ทรัพยากรมากขึ้น และมี Overhead ในการสลับ Context ระหว่าง Thread
- Asynchronous: ใช้ Thread เดียว (หรือจำนวนน้อยกว่า) เพื่อจัดการ Task หลายอย่าง โดยสลับการทำงานระหว่าง Task อย่างรวดเร็ว ทำให้ประหยัดทรัพยากรและเพิ่มประสิทธิภาพ
โดยหลักการทำงานของ Asynchronous ใน Rust คือ Rust จะใช้แนวคิดของ Futures และ Async/Await ในการจัดการ Asynchronous Programming
- Future คือ Object ที่แทนผลลัพธ์ของการคำนวณแบบ Asynchronous ซึ่งอาจจะยังไม่เสร็จสิ้นในทันที เมื่อการคำนวณเสร็จสิ้น Future จะให้ผลลัพธ์ออกมา
- Async/Await เป็น Syntax ที่ช่วยให้เขียน code Asynchronous ได้ง่ายขึ้น คล้ายกับการเขียน code Synchronous (ถ้าใครที่คุ้นชินกับ javascript ก็จะทรงเดียวกันเลย)
async
: ใช้กับ Function หรือ Block เพื่อระบุว่า code ภายในเป็นแบบ Asynchronousawait
: ใช้ภายในasync
Function เพื่อรอให้ Future เสร็จสิ้น
ตัวอย่างการใช้งาน Asynchronous ใน Rust
จาก code
- เราใช้
tokio::main
ซึ่งเป็น Macro จาก Cratetokio
เพื่อกำหนดว่า functionmain
เป็นแบบ Asynchronous Runtime async
block สร้าง Future ที่แทนการทำงานของแต่ละ Tasksleep(Duration::from_secs(n)).await
ทำให้โปรแกรมรอn
วินาทีโดยไม่ Block Threadtokio::join!
รอให้ Future หลายตัวเสร็จสิ้นพร้อมกัน- ผลลัพธ์ที่ได้คือ Task 2 จะเสร็จก่อน Task 1 เพราะใช้เวลารอน้อยกว่า แต่โปรแกรมจะไม่ Block และรอ Task 1 ให้เสร็จก่อน Task 2
อธิบายเพิ่มเติมเรื่อง tokio
Tokio (https://docs.rs/tokio/) เป็น Asynchronous Runtime สำหรับภาษา Rust ซึ่งเป็นเครื่องมือสำคัญสำหรับการเขียนโปรแกรมแบบ Asynchronous โดย Tokio ช่วยจัดการการทำงานของ Futures และ Tasks ทำให้โปรแกรมสามารถทำงานหลายอย่างพร้อมกันได้ โดยไม่ต้องสร้าง Thread ใหม่จำนวนมาก โดยหน้าที่หลักๆของ Tokio คือ
- Tokio ทำหน้าที่เป็น Runtime สำหรับการทำงานแบบ Asynchronous ใน Rust ตั้งแต่
- Executor ทำหน้าที่ Run Futures โดยการเรียก Method
poll
ของ Future วนไปเรื่อยๆ จนกว่า Future จะเสร็จสิ้น - Reactor ทำหน้าที่จัดการ Event ที่มาจากระบบปฏิบัติการ เช่น I/O Event (การอ่านเขียนไฟล์ หรือ Network) และส่ง Event เหล่านี้ไปยัง Executor เพื่อให้ Executor ปลุก Future ที่เกี่ยวข้องให้ทำงานต่อ
- Scheduler จัดการการทำงานของ Task และกระจายงานไปยัง Thread ต่างๆ ใน Thread Pool
- Timer จัดการการหน่วงเวลาและการตั้งเวลา
- Executor ทำหน้าที่ Run Futures โดยการเรียก Method
- รวมถึง Tokio มี Library และ Crate มากมายที่สร้างขึ้นบน Tokio ทำให้สามารถใช้งาน function ต่างๆ ได้ง่ายขึ้น เช่น
reqwest
(สำหรับทำ HTTP Request),tokio-postgres
(สำหรับเชื่อมต่อกับ PostgreSQL Database) ,tonic
(สำหรับ gRPC)
ขยายความเพิ่มอีกเล็กน้อย Asynchronous Runtime ในภาษา Rust นั้นเป็นส่วนขยายของ Runtime หลักของ Rust ซึ่งช่วยเพิ่มเครื่องมือและสภาพแวดล้อมที่จำเป็นสำหรับการทำงานของ code แบบ Asynchronous โดย
- ในขณะที่ runtime หลักของ Rust มุ่งเน้นไปที่การทำงานแบบ Synchronous
- บน Thread เดียว Asynchronous Runtime เช่น Tokio ได้เพิ่มความสามารถในการจัดการ Concurrency และการจัดการ I/O, ตัวจับเวลา และเหตุการณ์แบบ Asynchronous เพิ่มเติมเข้ามา ซึ่งทำได้ผ่านองค์ประกอบต่าง ๆ เช่น Event Loop ที่คอยตรวจจับเหตุการณ์ และ Scheduler ที่จัดการการทำงานของ Tasks แบบ Asynchronous
ส่วน การทำงานร่วมกันระหว่าง runtime ของ Rust และ runtime แบบ Asynchronous เช่น Tokio นั้น ขึ้นอยู่กับการผสานที่เกิดจาก Macro อย่าง #[tokio::main]
ซึ่ง Macro เหล่านี้ทำหน้าที่ปรับเปลี่ยน function main
ให้สามารถเชื่อมโยงระหว่างการประมวลผลแบบ Synchronous ของ Rust และสภาพแวดล้อมแบบ Asynchronous ที่ runtime ของ Tokio จัดเตรียมไว้ได้
เมื่อโปรแกรมเริ่มทำงาน runtime ของ Tokio จะถูกเริ่มต้นภายใน Runtime หลักของ Rust ซึ่งสิ่งนี้จะช่วยตั้งค่า Event Loop และ Scheduler ของ Tokio ทำให้มันสามารถจัดการ Tasks แบบ Asynchronous ได้อย่างเต็มที่
เมื่อทุกอย่างเรียบร้อย Runtime หลักของ Rust จะยังคงจัดการการทำงานโดยรวมของโปรแกรมต่อไป ในขณะที่ Tokio จะดูแลการทำงานของ Tasks แบบ Asynchronous โดยการสื่อสารผ่านคำสั่ง await วิธีการแบบ Cooperative นี้ช่วยผสานจุดเด่นของ runtime ทั้งสอง ทำให้การพัฒนา code แบบ Asynchronous ใน Rust เป็นไปได้อย่างมีประสิทธิภาพและราบรื่นภายใต้โมเดลการทำงานของ Rust ได้
Summary & More
บทความนี้ได้สำรวจหัวข้อพื้นฐานที่สำคัญในภาษา Rust ตั้งแต่เรื่องของ Collection ประเภทต่างๆ เช่น Vector, String และ Hash Map ซึ่งเป็นโครงสร้างข้อมูลที่ใช้บ่อยในการจัดเก็บและจัดการข้อมูล ต่อมาได้อธิบายเรื่อง Generics ซึ่งเป็นกลไกที่ช่วยให้เขียน code ที่รองรับหลายชนิดข้อมูลได้โดยไม่ต้องเขียน code ซ้ำซ้อน รวมถึง Trait ซึ่งเป็นเหมือน Interface ในภาษาอื่นๆ ที่กำหนดชุดของ function ที่ประเภทต่างๆ สามารถนำมาใช้ได้
รวมถึงได้อธิบายถึง Smart Pointers ซึ่งเป็นโครงสร้างข้อมูลที่ช่วยจัดการหน่วยความจำอัตโนมัติ ทำให้ code มีความปลอดภัยและลดโอกาสเกิดข้อผิดพลาด และ Lifetime ซึ่งเป็นแนวคิดที่ใช้จัดการอายุการใช้งานของข้อมูลเพื่อป้องกัน dangling references และ concurrency ซึ่ง Rust มีเครื่องมือต่างๆ ที่ช่วยในการจัดการ concurrency ได้อย่างปลอดภัยและมีประสิทธิภาพ
ส่วนในเรื่อง concurrency ได้กล่าวถึงการสร้าง Thread การส่งข้อมูลระหว่าง Thread และ asynchronous programming ซึ่งเป็นรูปแบบการเขียนโปรแกรมที่ช่วยให้โปรแกรมทำงานหลายอย่างพร้อมกันได้โดยไม่ต้องสร้าง Thread ใหม่ทุกครั้ง โดยรวมแล้ว บทความนี้ครอบคลุมหัวข้อพื้นฐานที่สำคัญในการเขียนโปรแกรมด้วยภาษา Rust ซึ่งจะช่วยให้ผู้อ่านมีความเข้าใจในหลักการและสามารถนำไปประยุกต์ใช้ในการพัฒนาโปรแกรมได้อย่างมีประสิทธิภาพและปลอดภัย
บทความเพิ่มเติมที่สามารถอ่านได้
- การจัดการ module อ่านเพิ่มเติมจากนี่ได้ (มีโอกาสจะมาเจาะลึกไปพร้อมๆกับ framework สักตัว) https://doc.rust-lang.org/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html
- https://medium.com/@be.munin/rust-เจาะลึกหน่วยความจำ-stack-heap-และ-virtual-memory-part-1-93c0cc77874f
- มารู้จักการเขียน code แบบ clean code กัน (ฉบับ Javascript)มี Video
มาแชร์เทคนิคการเขียน code แบบ clean ฉบับ Javascriptกัน ว่ามีกี่วิธีที่สามารถทำให้ code สะอาดขึ้นได้บ้าง
- มาลองเล่น Gemini Pro กันมี Video มี Github
มาทำความรู้จักกับ Gemini Pro และ Prompt design กันว่าเราสามารถเอา Gemini ไปทำอะไรได้บ้าง
- รู้จักกับ Next.js 14 แบบ Quick Overviewมี Video มี Github
พาทัวร์ feature ต่างๆของ Next.js กันแบบรวดเร็วกัน ดูทุก feature ของ Next กัน
- NoSQL, MongoDB และ ODMมี Video
พามารู้จักกับ NoSQL พื้นฐาน database อีกตัวหนึ่ง ว่ามันคืออะไร มันเกิดขึ้นมาจากโจทย์อะไร มีลักษณะที่แตกต่างกับ SQL และมีวิธีการใช้งานที่ต่างกับ SQL ยังไงบ้าง