Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เชี่ยวชาญการตรวจสอบประเภทใน Ruby: หลีกเลี่ยงข้อผิดพลาดรันไทม์ทั่วไป

มาเริ่มบทความนี้ด้วยเกมทายเล็กน้อย: คุณคิดว่าข้อผิดพลาดที่ AppSignal ติดตามมากที่สุดในแอปพลิเคชัน Ruby คืออะไร?

สมมติได้ว่าเป็นเรื่องสมเหตุสมผลที่หลายคนจะตอบ NoMethodError ข้อยกเว้นที่เกิดจากการเรียกเมธอดที่ไม่มีอยู่บนอ็อบเจ็กต์ บางครั้งอาจเกิดจาก typo ในชื่อเมธอด แต่บ่อยครั้งมันเป็นผลจากการเรียกเมธอดบนอ็อบเจ็กต์ประเภทผิด ซึ่งมักเป็น nil ที่ไม่คาดคิด มีอะไรที่เราสามารถทำได้ในฐานะนักพัฒนา Ruby เพื่อลดความถี่ของข้อผิดพลาดเช่นนี้?

ประเภท (Types) ช่วยชีวิตได้ไหม?

นอกจากการเลือก text editor หรือภาษาโปรแกรมมิ่ง ไม่มีหัวข้อไหนทำให้เกิดการโต้เถียงร้อนแรงได้เร็วกว่าการอภิปรายเกี่ยวกับ type systems เราจะไม่มีเวลาไปรายละเอียดที่นี่ แตบทความ "What To Know Before Debating Type Systems" ของ Chris Smith ทำได้ดีเยี่ยม

ในมุมมองกว้างที่สุด ระบบประเภทสามารถแบ่งเป็นสองหมวดหลัก—static และ dynamic ในขณะที่ static เกิดขึ้นล่วงหน้า (ผ่าน compiler หรือเครื่องมือแยก) dynamic type checking เกิดขึ้นระหว่าง runtime ซึ่งอาจนำไปสู่ exceptions หากประเภทจริงไม่ตรงกับความคาดหวังของนักพัฒนา

ผู้สนับสนุนทั้งสองฝ่ายมีความคิดที่แข็งแกร่ง แต่ก็มีความเข้าใจผิดหลายอย่างลอยล้ำ: static typing ไม่ได้ต้องการ type annotations มากมาย—compiler สมัยใหม่หลายตัวสามารถคำนวณประเภทได้เอง กระบวนการที่รู้จักว่า "type inference" ในทางกลับกัน dynamically typed languages ไม่ได้แสดงอัตราข้อบกพร่องที่สูงกว่าคู่คู่ static อย่างมีนัยสำคัญ

Duck Typing

Ruby เองเป็นภาษาที่ตรวจสอบประเภทแบบ dynamic และปฏิบัติตามแนวทาง "duck typing":

ถ้ามันเดินเหมือนเป็ด และเห่เหมือนเป็ด มันต้องเป็นเป็ด

นี่หมายความว่า นักพัฒนา Ruby โดยทั่วไปไม่กังวลมากนักเกี่ยวกับประเภทของอ็อบเจ็กต์ แต่ว่ามันตอบสนองต่อ "messages" (หรือเมธอด) บางอย่างหรือไม่

ทำไมต้องยุ่งกับ static typing ใน Ruby ล่ะ? แม้จะไม่ใช่ยาทุกข้องที่จะทำให้โค้ดของคุณปราศจากบั๊กโดยวิเศษ มันก็ให้ประโยชน์บางอย่าง:

  • Correctness: static typing ป้องกัน บางคลาส ของบั๊ก ได้ดี เช่น NoMethodError ที่กล่าวมาข้างต้น
  • Tooling: บ่อยครั้ง การมีข้อมูล static type ระหว่างการพัฒนานำไปสู่ tooling ที่ดีกว่า (เช่น refactoring support ใน IDEs ฯลฯ)
  • Documentation: ภาษา static typed หลายภาษามีเครื่องมือ documentation built-in ที่ยอดเยี่ยม Haskell's Hoogle ใช้สิ่งนี้ได้ดีโดยเสนอ search engine ที่ค้นหา functions ได้จาก type signatures
  • Performance: ยิ่งข้อมูลที่ compiler มีมาก ยิ่ง optimization ประสิทธิภาพที่อาจนำไปใช้ได้มาก

รายการนี้ไม่ใช่ exhaustive และสามารถหาตัวอย่างตรงข้ามได้สำหรับจุดส่วนใหญ่ แต่แน่นอนว่ามีแก่นความจริงอยู่

Gradual Type Checking

ในไม่กี่ปีที่ผ่านมา แนวทางที่เรียกกันว่า "gradual type checking" ได้เข้ามาในภาษา dynamic typed ต่างๆ: จาก TypeScript สำหรับ JS, Hack สำหรับ PHP และ mypy สำหรับ Python สิ่งที่ร่วมกันคือพวกมันไม่ต้องการ all-or-nothing แต่อนุญาตให้นักพัฒนาเพิ่มข้อมูลประเภทให้กับตัวแปรและนิพจน์ทีละน้อยตามที่เห็นพอเหมาะ นี่มีประโยชน์โดยเฉพาะสำหรับ codebases ใหญ่ที่มีอยู่แล้ว ซึ่งสามารถ static check ส่วนสำคัญที่สุดของระบบ ในขณะที่ปล่อยส่วนที่เหลือไม่มี type และตรวจสอบที่ runtime ทุก solution การตรวจสอบประเภทสำหรับ Ruby ที่เราจะสำรวจในส่วนที่เหลือของบทความนี้ปฏิบัติตามแนวทางเดียวกัน

ตัวเลือก

หลังจากดูว่าทำไมนักพัฒนา Ruby อาจอยากเพิ่ม static type checking เข้า workflow การพัฒนา ถึงเวลาสำรวจตัวเลือกยอดนิยมบางอย่างสำหรับการทำเช่นนั้น อย่างไรก็ตาม สำคัญต้องทราบว่า แนวคิดการเพิ่ม static type checking ให้ Ruby ไม่ใช่เรื่องใหม่ นักวิจัยจาก University of Maryland ทำงานบน Ruby extension ชื่อ Diamondback Ruby (Druby) ตั้งแต่ 2009 และ Tufts University Programming Language Group เผยแพร่ paper ชื่อ The Ruby Type Checker ในปี 2013 ซึ่งนำไปสู่โปรเจกต์ RDL ที่เสนอ type checking และ design-by-contract capabilities เป็นไลบรารี

Sorbet

พัฒนาโดย Stripe Sorbet เป็น type checking solution สำหรับ Ruby ที่ได้รับการพูดถึงมากที่สุดในตอนนี้ ไม่เล็กนักเพราะบริษัทใหญ่อย่าง Shopify, GitLab, Kickstarter และ Coinbase เป็น early adopters ในช่วง closed beta มันถูกประกาศครั้งแรกใน Ruby Kaigi ปีที่แล้ว และเห็น public release ครั้งแรกในวันที่ 20 มิถุนายนของปีนี้ Sorbet เขียนด้วย modern C++ และแม้ Matz จะชอบแบบอื่น (quote: "I hate type annotations") แต่เลือกแนวทางที่อาศัย type annotations สิ่งน่าสนใจอย่างหนึ่งของ Sorbet คือ มันเลือก combination ของ static และ dynamic type checking เนื่องจากธรรมชาติ dynamic และความสามารถ metaprogramming ของ Ruby ท้าทาย static type systems

# typed: true class Test extend T::Sig sig {params(x: Integer).returns(String)} def to_s(x) x.to_s end end

เพื่อเปิดใช้งาน type checking เราต้องเพิ่ม magic comment # typed: true และ extend คลาสด้วยโมดูล T::Sig การ annotate ประเภทจริงระบุด้วยเมธอด sig:

sig {params(x: Integer).returns(String)}

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

Test.new.to_s("42") # Expected Integer but found String("42") for argument x

นอกจาก basic checks นี้ Sorbet มีทริกเพิ่มเติมอีกมาก เช่น มันช่วยเราหลีกเลี่ยง NoMethodError บน nil ที่น่ากลัว:

users = T::Array[User].new user = users.first user.username # Method username does not exist on NilClass component of T.nilable(User)

ชิ้นโค้ดข้างต้นกำหนด array ว่างของอ็อบเจ็กต์ User และเมื่อเราพยายามเข้าถึง element แรก (ซึ่งจะคืน nil) Sorbet จะเตือนให้ถูกต้องว่าไม่มีเมธอดชื่อ username บน NilClass อย่างไรก็ตาม หากเราแน่ใจว่าค่าบางอย่างไม่เคยเป็น nil เราสามารถใช้ T.must เพื่อบอก Sorbet ว่านั้น:

users = T::Array[User].new user = T.must(users.first) user.username

โค้ดข้างต้นจะ type check ผ่าน แต่อาจนำไปสู่ runtime exception ดังนั้นใช้ฟีเจอร์นี้ด้วยความระมัดระวัง

มีอะไรอีกมากที่ Sorbet ทำได้: dead code detection, type pinning (โดยสืบเนียน commit ตัวแปรให้เป็นประเภทหนึ่ง เช่น หลังจาก assign string แล้ว ไม่สามารถ assign integer ได้) หรือความสามารถในการกำหนด interfaces

เพิ่มเติม Sorbet ยังทำงานกับไฟล์ "Ruby Interface" (rbi) ที่มันเก็บในโฟลเดอร์ sorbet/ ใน working directory ปัจจุบัน นี่ช่วยให้เราสร้าง interface definitions สำหรับ gems ทั้งหมดที่โปรเจกต์ใช้ ซึ่งช่วยให้เราหา type errors เพิ่มเติมได้

มีอะไรมากเกินกว่าจะครอบคลุมในบทความเดียว (เช่น ระดับความเข้มงวดที่แตกต่างกัน หรือ metaprogramming plugins) แต่เอกสารของมันค่อนข้างดีแล้วและเปิดรับ PRs

Steep

ทางเลือกที่รู้จักมากที่สุดนอกจาก Sorbet คือ Steep โดย Soutaro Matsumoto มันไม่ใช้ annotations และไม่ทำ type inference เอง แทนที่จะพึ่งพา .rbi files ใน directory sig อย่างสมบูรณ์

เริ่มจากคลาส Ruby เรียบง่ายดังนี้:

class User attr_reader :first_name, :last_name, :address def initialize(first_name, last_name, address) @first_name = first_name @last_name = last_name @address = address end def full_name "#{first_name} #{last_name}" end end

เราสามารถ scaffold user.rbi เริ่มต้นด้วยคำสั่ง:

$ steep scaffold user.rb > sig/user.rbi

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

class User @first_name: any @last_name: any @address: any def initialize: (any, any, any) -> any def full_name: () -> String end 

อย่างไรก็ตาม หากเราลอง type check ณ จุดนี้ เราจะเจอข้อผิดพลาด:

$ steep check user.rb:11:7: NoMethodError: type=::User, method=first_name (first_name) user.rb:11:21: NoMethodError: type=::User, method=last_name (last_name) 

เหตุผลที่เราเห็นนี้คือ Steep ต้องการ comment พิเศษเพื่อรู้ว่าเมธอดไหนถูกกำหนดผ่าน attr_readers มาเพิ่มมัน:

# @dynamic first_name, last_name, address attr_reader :first_name, :last_name, :address

เพิ่มเติม เราต้องเพิ่ม definitions สำหรับเมธอดในไฟล์ .rbi ที่สร้างขึ้น ขณะอยู่ที่นี่ เราก็เปลี่ยน signatures จาก any เป็นประเภทจริง:

class User @first_name: String @last_name: String @address: Address def initialize: (String, String, Address) -> any def first_name: () -> String def last_name: () -> String def address: () -> Address def full_name: () -> String end 

ตอนนี้ ทุกอย่างทำงานตามที่คาดหวัง และ steep check ไม่คืนข้อผิดพลาด

นอกจากที่เราเห็น Steep ยังรองรับ generics (เช่น Hash<Symbol, String>) และ union types ซึ่งแสดง either-or choice ระหว่างหลายประเภท ตัวอย่างเช่น เมธอด top_post ของ user อาจคืน post ที่มีอันดับสูงสุดที่ user เขียน หรือ nil หากยังไม่ได้เขียนอะไร นี่แสดงผ่าน union type (Post | nil) และ signature ที่ตรงกันจะเป็นแบบนี้:

def top_post: () -> (Post | nil) 

แม้ Steep จะมีฟีเจอร์น้อยกว่า Sorbet มันก็ยังเป็นเครื่องมือที่ช่วยเหลือได้ และดูเหมาะสมกับสิ่งที่ Matz คาดหวัง type checking ใน Ruby 3 จะเป็น

Ruby Type Profiler

Yusuke Endoh (รู้จักดีในวงนักพัฒนา Ruby ว่า "mame") จาก Cookpad กำลังทำงานบน so-called level 1 type checker ชื่อ Ruby Type Profiler ต่างจาก solutions อื่นที่นำเสนอที่นี่ มันไม่ต้องการ signature files หรือ type annotations แต่พยายาม infer มากที่สุดเท่าที่จะเป็นไปได้เกี่ยวกับโปรแกรม Ruby ขณะ parsing มันแม้จับปัญหาที่อาจเกิดขึ้นได้น้อยกว่า Steep หรือ Sorbet มาก แต่ใช้ได้โดยไม่มีค่าใช้จ่ายเพิ่มเติมให้นักพัฒนา

สรุป

แม้ никтоไม่สามารถทำนายอนาคตได้ แต่มองเหมือนว่า type checking ใน Ruby เป็นสิ่งที่จะอยู่ต่อไป ปัจจุบันมีความพยายามกำลังดำเนินไปเพื่อ standardize บน "Ruby Signature Language" สำหรับใช้ในไฟล์ .rbi (อาจ scaffold โดย Ruby Type Profiler) เพื่อให้นักพัฒนาใช้ tool ที่ชอบได้ Steep อนุญาตให้ผู้เขียนไลบรารี ship type information พร้อม gems ของพวกเขา และ Sorbet มีกลไกคล้ายกันในรูปของ sorbet-typed ซึ่งได้แรงบันดาลใจจาก DefinitelyTyped repository สำหรับ TypeScript definitions หากคุณสนใจช่วยรูปทรงอนาคตของ type checking ใน Ruby ตอนนี้เป็นเวลาที่ดีที่จะมีส่วนร่วม!