RBS เป็นชื่อของภาษารูปแบบไวยากรณ์ชนิดข้อมูลใหม่สำหรับ Ruby RBS ช่วยให้คุณเพิ่มบันทึกชนิดข้อมูล (type annotations) ลงในโค้ด Ruby ของคุณในไฟล์ที่มีนามสกุลใหม่คือ .rbs โค้ดจะมีลักษณะดังนี้:
class MyClass def my_method : (my_param: String) -> String end ด้วยการให้บันทึกชนิดข้อมูลผ่าน RBS คุณจะได้รับประโยชน์ดังนี้:
- วิธีที่สะอาดและกระชับในการกำหนดโครงสร้างของโค้ดเบส
- วิธีที่ปลอดภัยกว่าในการเพิ่มชนิดข้อมูลลงในโค้ดเก่า (legacy code) ผ่านไฟล์แทนการเปลี่ยนแปลงคลาสโดยตรง
- ศักยภาพในการรวมกับเครื่องมือตรวจสอบชนิดข้อมูลแบบคงที่ (static) และแบบไดนามิก (dynamic) ได้อย่างกว้างขวาง
- ฟีเจอร์ใหม่ๆ สำหรับจัดการกับการโอเวอร์โหลดเมธอด (method overloading), ดักทายปิง (duck typing), อินเทอร์เฟซแบบไดนามิก และอื่นๆ
แต่ก่อน! ไม่มีเครื่องมือตรวจสอบชนิดข้อมูลแบบคงที่อย่าง Sorbet และ Steep อยู่แล้วหรือ?
มีจริง และเยอดเยี่ยมมาก! อย่างไรก็ตาม หลังจากการอภิปรายเป็นเวลา 4 ปี และเครื่องมือตรวจสอบชนิดข้อมูลที่สร้างโดยชุมชนหลายตัว ทีมคอมมิทเตอร์ของ Ruby คิดว่าถึงเวลาแล้วที่จะกำหนดมาตรฐานบางอย่างสำหรับการสร้างเครื่องมือตรวจสอบชนิดข้อมูล
RBS เป็นภาษาอย่างเป็นทางการ และกำลังจะมาพร้อมกับ Ruby 3
นอกจากนี้ เนื่องจากลักษณะการพิมพ์แบบไดนามิกของ Ruby รวมถึงรูปแบบทั่วไปอย่าง Duck Typing และการโอเวอร์โหลดเมธอด จึงมีการวางมาตรการป้องกันบางอย่างเพื่อให้แน่ใจถึงวิธีการที่ดีที่สุด ซึ่งเราจะได้เห็นรายละเอียดในไม่ช้า
การติดตั้ง Ruby 3
เพื่อปฏิบัติตามตัวอย่างที่แสดงที่นี่ เราจำเป็นต้องติดตั้ง Ruby 3
คุณสามารถทำได้โดยปฏิบัติตามคำแนะนำอย่างเป็นทางการ หรือผ่าน ruby-build หากคุณต้องการจัดการหลายเวอร์ชันของ Ruby
ทางเลือกอื่นคือ คุณสามารถติดตั้ง gem rbs โดยตรงเข้าโปรเจกต์ปัจจุบันของคุณ:
gem install rbs การพิมพ์แบบคงที่ (Static) เทียบกับแบบไดนามิก (Dynamic)
ก่อนจะไปต่อ เรามาชี้แจงแนวคิดนี้กันก่อน ภาษาที่พิมพ์แบบไดนามิกเปรียบเทียบกับแบบคงที่อย่างไร?
ในภาษาที่พิมพ์แบบไดนามิก เช่น Ruby และ JavaScript ไม่มีชนิดข้อมูลที่กำหนดไว้ล่วงหน้าสำหรับอินเตอร์พรีเตอร์ให้เข้าใจวิธีดำเนินการเมื่อเกิดการดำเนินการที่ห้ามในขณะรันไทม์ (runtime)
นี่ตรงข้ามกับสิ่งที่คาดหวังจากการพิมพ์แบบคงที่ ภาษาที่พิมพ์แบบคงที่ เช่น Java และ C จะตรวจสอบชนิดข้อมูลในขณะคอมไพล์ไทม์ (compile time)
มาดูตัวอย่างโค้ด Java ด้านล่างเป็นข้อมูลอ้างอิง:
int number = 0; number = "Hi, number!"; สิ่งนี้เป็นไปไม่ได้ในการพิมพ์แบบคงที่ เนื่องจากบรรทัดที่สองจะโยนข้อผิดพลาด:
error: incompatible types: String cannot be converted to int ตอนนี้ มาลองตัวอย่างเดียวกันใน Ruby:
number = 0; number = "Hi, number!"; puts number // พิมพ์ "Hi, number!" สำเร็จ ใน Ruby ชนิดข้อมูลของตัวแปรจะเปลี่ยนไปได้ตามต้องการ ซึ่งหมายความว่าอินเตอร์พรีเตอร์รู้วิธีสลับจากชนิดหนึ่งไปอีกชนิดหนึ่งแบบไดนามิก
แนวคิดนี้มักสับสนกับภาษาที่ พิมพ์แบบแข็งแกร่ง (strongly typed) เทียบกับ พิมพ์แบบอ่อนแอ (weakly typed)
Ruby ไม่ได้เป็นภาษาที่พิมพ์แบบไดนามิกเพียงอย่างเดียว แต่ยังเป็นภาษาที่พิมพ์แบบแข็งแกร่งอีกด้วย ซึ่งหมายความว่ามันอนุญาตให้ตัวแปรเปลี่ยนชนิดข้อมูลระหว่างรันไทม์ได้ แต่ไม่อนุญาตให้คุณทำการผสมผสานชนิดข้อมูลที่ "บ้าคลั่ง" ได้
มาดูตัวอย่างต่อไป (ใน Ruby) ที่ปรับจากตัวอย่างก่อนหน้า:
number = 2; sum = "2" + 2; puts sum คราวนี้เราพยายามบวกตัวเลขสองตัวที่อยู่ในชนิดข้อมูลต่างกัน (Integer และ String) Ruby จะโยนข้อผิดพลาดดังนี้:
main.rb:2:in `+': no implicit conversion of Integer into String (TypeError) from main.rb:2:in `<main>' กล่าวอีกนัยหนึ่ง Ruby กำลังบอกว่างานในการคำนวณที่ซับซ้อนซึ่งเกี่ยวข้องกับชนิดข้อมูลที่อาจแตกต่างกันนั้น เป็นหน้าที่ของคุณทั้งหมด
JavaScript ในทางกลับกัน ซึ่งเป็นภาษาที่พิมพ์แบบอ่อนแอ และ แบบไดนามิก อนุญาตให้โค้ดเดียวกันนี้ทำงานด้วยผลลัพธ์ที่ต่างออกไป:
> number = 2 sum = "2" + 2 console.log(sum) > 22 // มันต่อสตริงทั้งสองค่าเข้าด้วยกัน RBS แตกต่างจาก Sorbet อย่างไร?
ข้อแรก จากแนวทางที่แต่ละตัวใช้ในการบันทึกชนิดข้อมูลโค้ด ในขณะที่ Sorbet ทำงานโดยการเพิ่มบันทึกชนิดข้อมูลอย่างชัดเจนทั่วทั้งโค้ดของคุณ RBS เพียงต้องการการสร้างไฟล์ใหม่ที่มีนามสกุล .rbs
ข้อได้เปรียบหลักของสิ่งนี้คือเมื่อเราคิดถึงการย้ายโค้ดเบสเก่า (legacy codebases) เนื่องจากไฟล์ต้นฉบับของคุณจะไม่ได้รับผลกระทบ จึงปลอดภัยกว่ามากในการนำไฟล์ RBS ไปใช้ในโปรเจกต์ของคุณ
ตามผู้สร้าง เป้าหมายหลักของ RBS คือ อธิบายโครงสร้าง ของโปรแกรม Ruby ของคุณ มันเน้นที่การกำหนดลายเซ็นของคลาส/เมธอดเท่านั้น
RBS เองไม่สามารถตรวจสอบชนิดข้อมูลได้ เป้าหมายของมันจำกัดอยู่ที่การกำหนดโครงสร้างเป็นพื้นฐานสำหรับเครื่องมือตรวจสอบชนิดข้อมูล (อย่าง Sorbet และ Steep) ทำหน้าที่ของพวกเขา
มาดูตัวอย่างการสืบทอด (inheritance) แบบง่ายๆ ใน Ruby:
class Badger def initialize(brand) @brand = brand end def brand? @brand end end class Honey < Badger def initialize(brand: "Honeybadger", sweet: true) super(brand) @sweet = sweet end def sweet? @sweet end end ยอดเยี่ยม! เพียงสองคลาสที่มีแอตทริบิวต์และชนิดข้อมูลที่อนุมานได้
ด้านล่างคือการแสดงผล RBS ที่เป็นไปได้:
class Brand attr_reader brand : String def initialize : (brand: String) -> void end class Honey < Brand @sweet : bool def initialize : (brand: String, ?sweet: bool) -> void def sweet? : () -> bool end ค่อนข้างคล้ายกันใช่ไหม? ความแตกต่างหลักในที่นี้คือชนิดข้อมูล เมธอด initialize ของคลาส Honey ตัวอย่างเช่น รับพารามิเตอร์หนึ่งเป็น String และหนึ่งเป็น boolean และไม่คืนค่าอะไร (void)
ในขณะเดียวกัน ทีม Sorbet กำลังทำงานใกล้ชิดกันในการสร้างเครื่องมือเพื่อให้เกิดความสามารถในการทำงานร่วมกันระหว่าง RBI (นามสกุลเริ่มต้นของ Sorbet สำหรับการกำหนดชนิดข้อมูล) และ RBS
เป้าหมายคือการวางพื้นฐานสำหรับ Sorbet และเครื่องมือตรวจสอบชนิดข้อมูลตัวอื่นๆ ให้เข้าใจวิธีใช้ไฟล์定义ชนิดข้อมูลของ RBS
เครื่องมือสร้างโครงร่าง (Scaffolding Tool)
หากคุณเพิ่งเริ่มต้นกับภาษานี้และมีโปรเจกต์อยู่แล้ว อาจจะยากที่จะเดาได้ว่าจะเริ่มต้นเพิ่มชนิดข้อมูลที่ไหนและอย่างไร
ด้วยความคิดนี้ในใจ ทีม Ruby จึงได้เตรียมเครื่องมือ CLI ที่มีประโยชน์มากชื่อ rbs ให้เราเพื่อสร้างโครงร่างชนิดข้อมูลสำหรับคลาสที่มีอยู่แล้ว
เพื่อแสดงคำสั่งที่มีอยู่ ให้พิมพ์ rbs help ในคอนโซลและตรวจสอบผลลัพธ์:
คำสั่งที่มีอยู่ในเครื่องมือ CLI
คำสั่งที่สำคัญที่สุดในรายการน่าจะเป็น prototype เนื่องจากมันวิเคราะห์ ASTs ของไฟล์โค้ดต้นทางที่ให้มาเป็นพารามิเตอร์เพื่อสร้างโค้ด RBS "ประมาณการ" (approximate)
ว่าเป็น "ประมาณการ" เพราะมันไม่ได้มีประสิทธิภาพ 100% เนื่องจากโค้ดเก่าของคุณส่วนใหญ่ไม่มีชนิดข้อมูล ส่วนใหญ่ของเนื้อหาที่สร้างโครงร่างจะออกมาแบบเดียวกัน RBS ไม่สามารถเดาชนิดข้อมูลบางอย่างได้หากไม่มีการกำหนดค่าชัดเจน ตัวอย่างเช่น
มาลองตัวอย่างอีกอันหนึ่งเป็นข้อมูลอ้างอิง คราวนี้เกี่ยวข้องกับสามคลาสที่แตกต่างกันในการสืบทอดแบบคาสเคด (cascaded inheritance):
class Animal def initialize(weight) @weight = weight end def breathe puts "Inhale/Exhale" end end class Mammal < Animal def initialize(weight, is_terrestrial) super(weight) @is_terrestrial = is_terrestrial end def nurse puts "I'm breastfeeding" end end class Cat < Mammal def initialize(weight, n_of_lives, is_terrestrial: true) super(weight, is_terrestrial) @n_of_lives = n_of_lives end def speak puts "Meow" end end แค่คลาสง่ายๆ ที่มีแอตทริบิวต์และเมธอด โปรดสังเกตว่ามีหนึ่งในนั้นที่ให้ค่า boolean เริ่มต้น ซึ่งจะสำคัญในการสาธิตว่ RBS สามารถเดาได้ด้วยตัวเองอย่างไร
ตอนนี้ เพื่อสร้างโครงร่างชนิดข้อมูลเหล่านี้ ให้รันคำสั่งต่อไปนี้:
rbs prototype rb animal.rb mammal.rb cat.rb คุณสามารถส่งไฟล์ Ruby หลายไฟล์ได้ตามต้องการ ผลลัพธ์ของการรันคำสั่งนี้คือ:
class Animal def initialize: (untyped weight) -> untyped def breathe: () -> untyped end class Mammal < Animal def initialize: (untyped weight, untyped is_terrestrial) -> untyped def nurse: () -> untyped end class Cat < Mammal def initialize: (untyped weight, untyped n_of_lives, ?is_terrestrial: bool is_terrestrial) -> untyped def speak: () -> untyped end ตามที่เราคาดการณ์ไว้ RBS ไม่สามารถเข้าใจชนิดข้อมูลส่วนใหญ่ที่เรามุ่งมั่นเมื่อสร้างคลาสได้
งานส่วนใหญ่ของคุณจะเป็นการเปลี่ยนอ้างอิง untyped ให้เป็นชนิดข้อมูลจริงด้วยตนเอง มีการอภิปรายบางอย่างที่มุ่งหาวิธีที่ดีกว่าในการทำสิ่งนี้กำลังเกิดขึ้นในชุมชนอยู่
เมตาโปรแกรมมิ่ง (Metaprogramming)
เมื่อพูดถึงเมตาโปรแกรมมิ่ง เครื่องมือ rbs จะไม่ช่วยได้มากนักเนื่องจากลักษณะไดนามิกของมัน
มาดูคลาสต่อไปนี้เป็นตัวอย่าง:
class Meta define_method :greeting, -> { puts 'Hi there!' } end Meta.new.greeting ผลลัพธ์ของการสร้างโครงร่างชนิดข้อมูลนี้จะเป็น:
class Meta end ดักทายปิง (Duck Typing)
Ruby ไม่ได้กังวลมากนักเกี่ยวกับลักษณะของออบเจ็กต์ (ชนิดข้อมูลของพวกมัน) แต่กังวลเกี่ยวกับสิ่งที่พวกมันทำได้ (สิ่งที่พวกมันทำ)
Duck typing เป็นสไตล์การเขียนโปรแกรมที่มีชื่อเสียงซึ่งทำงานตามคติ:
"ถ้าออบเจ็กต์พฤติกรรมเหมือนเป็ด (พูด, เดิน, บิน, ฯลฯ) แล้วมันก็คือเป็ด"
กล่าวอีกนัยหนึ่ง Ruby จะจัดการมันเสมอเหมือนเป็ด แม้ว่าการกำหนดและชนิดข้อมูลต้นฉบับของมันไม่ได้ควรจะแทนเป็ดก็ตาม
อย่างไรก็ตาม Duck typing อาจซ่อนรายละเอียดของการนำโค้ดไปใช้งานที่สามารถกลายเป็นเรื่องยุ่งยากและอ่าน/ค้นหาไม่ง่ายได้
RBS ได้นำเสนอแนวคิดของ ชนิดอินเทอร์เฟซ (interface types) ซึ่งเป็นชุดของเมธอดที่ไม่ขึ้นอยู่กับคลาสหรือโมดูลที่เป็นรูปธรรมใดๆ
มาลองนำตัวอย่างการสืบทอดสัตว์ก่อนหน้านี้มาใช้ และสมมติว่าเรากำลังเพิ่มระดับลำดับชั้นใหม่สำหรับสัตว์บก ซึ่ง Cat ของเราจะสืบทอดมาจากนั้น:
class Terrestrial < Animal def initialize(weight) super(weight) end def run puts "Running..." end end เพื่อป้องกันไม่ให้ออบเจ็กต์ลูกที่ไม่ใช่สัตว์บกวิ่งได้ เราสามารถสร้างอินเทอร์เฟซที่ตรวจสอบชนิดเฉพาะสำหรับการกระทำดังกล่าว:
interface _CanRun # ต้องการออปอเรเตอร์ `<<` ซึ่งยอมรับออบเจ็กต์ `Terrestrial` def <<: (Terrestrial) -> void end เมื่อแมปโค้ด RBS ไปยังเมธอด run เฉพาะเจาะจง ลายเซ็นจะเป็น:
def run: (_CanRun) -> void ทุกครั้งที่ใครพยายามส่งผ่านสิ่งอื่นนอกจากออบเจ็กต์ Terrestrial ให้กับเมธอด เครื่องมือตรวจสอบชนิดข้อมูลจะแน่ใจว่าบันทึกข้อผิดพลาด
ยูเนียนชนิด (Union Types)
นอกจากนี้ ในหมู่ Rubyists ยังพบได้บ่อยที่จะมีนิพจน์ที่เก็บค่าชนิดข้อมูลต่างๆ
def fly: () -> (Mammal | Bird | Insect) RBS รองรับยูเนียนชนิดโดยเพียงแค่เชื่อมต่อพวกมันผ่านออปอเรเตอร์ ไพพ์ (pipe)
การโอเวอร์โหลดเมธอด (Method Overloading)
อีกแนวปฏิบัติทั่วไป (ในหลายภาษาโปรแกรมมิ่งจริงๆ) คือการอนุญาตให้โอเวอร์โหลดเมธอด ซึ่งคลาสสามารถมีเมธอดมากกว่าหนึ่งที่มีชื่อเดียวกันแต่ลายเซ็นแตกต่างกัน (เช่น ชนิดหรือจำนวนพารามิเตอร์ ลำดับของพวกมัน ฯลฯ)
มาลองตัวอย่างที่สัตว์สามารถคืนค่าพี่น้องวิวัฒนาการที่ใกล้ที่สุดได้:
def evolutionary_cousins: () -> Enumerator[Animal, void] | { (Animal) -> void } -> void ด้วยวิธีนี้ RBS ช่วยให้เราสามารถกำหนดได้อย่างชัดเจนว่าสัตว์ตัวใดตัวหนึ่งจะมีพี่น้องวิวัฒนาการเพียงตัวเดียวหรือหลายตัว
TypeProf
พร้อมกัน ทีม Ruby ยังได้เริ่มโปรเจกต์ใหม่ชื่อ typeprof ซึ่งเป็นอินเตอร์พรีเตอร์ระดับชนิดข้อมูลของ Ruby แบบทดลอง ที่มุ่งมั่นวิเคราะห์และ (พยายาม) สร้างเนื้อหา RBS
มันทำงานโดยการตีความเชิงนามธรรม (abstract interpretation) และยังอยู่ในขั้นตอนแรกๆ ของการปรับปรุงให้ดีขึ้น จึงควรระวังเมื่อใช้เพื่อวัตถุประสงค์การผลิต (production)
เพื่อติดตั้ง ให้เพียงแค่เพิ่ม gem เข้าโปรเจกต์ของคุณ:
gem install typeprof โปรดทราบว่ามันต้องการเวอร์ชัน Ruby สูงกว่า 2.7
มาดูเวอร์ชันต่อไปนี้ของคลาส Animal:
class Animal def initialize(weight) @weight = weight end def die(age) if age > 50 true elsif age <= 50 false elsif age < 0 nil end end end Animal.new(100).die(65) จากสิ่งที่เกิดขึ้นภายในเมธอด age และการเรียกเมธอดเดียวกันต่อไป TypeProf สามารถอนุมานชนิดข้อมูลที่ถูกจัดการในโค้ดได้อย่างฉลาด
เมื่อคุณรันคำสั่ง typeprof animal.rb ผลลัพธ์ควรจะเป็นดังนี้:
## Classes class Animal @weight: Integer def initialize: (Integer weight) -> Integer def die: (Integer age) -> bool? end มันเป็นเครื่องมือที่ทรงพลังซึ่งมีอะไรมากมายที่จะเสนอให้กับโปรเจกต์ที่มีโค้ดเยอะอยู่แล้ว
การรวมกับ VS Code
ปัจจุบัน ไม่มีปลั๊กอิน VS Code มากนักสำหรับจัดการกับ RBS สำหรับการจัดรูปแบบ ตรวจสอบโครงสร้าง ฯลฯ โดยเฉพาะเพราะมันยังค่อนข้างใหม่
อย่างไรก็ตาม หากคุณค้นหาในสโตร์ด้วยคำว่า "RBS" คุณอาจพบปลั๊กอินหนึ่งตัวชื่อ ruby-signature ซึ่งจะช่วยในการไฮไลท์ไวยากรณ์ (syntax highlighting) ตามที่แสดงด้านล่าง:
การไฮไลท์ไวยากรณ์ RBS ใน VS Code
สรุป
RBS เพิ่งเพ็ทใหม่และแล้วแต่ก็เป็นขั้นตอนสำคัญสู่โค้ดเบส Ruby ที่ปลอดภัยขึ้น
ปกติแล้ว ในเวลาอันจะมาถึง เครื่องมือใหม่และโปรเจกต์โอเพนซอร์สจะขึ้นมารองรับมัน เช่น RBS Rails สำหรับสร้างไฟล์ RBS สำหรับแอปพลิเคชัน Ruby on Rails
อนาคตมีสิ่งยอดเยี่ยมรออยู่ข้างหน้าสำหรับชุมชน Ruby ด้วยแอปพลิเคชันที่ปลอดภัยและปราศจากบั๊กมากขึ้น รอไม่及แล้ว!