พื้นที่การออกแบบสำหรับตัวแปรในภาษาการเขียนโปรแกรมมีขนาดใหญ่ แต่จนถึงขณะนี้ (afaik) พบวิธีแก้ปัญหาที่ดีเพียงวิธีเดียวเท่านั้น และยังมีวิธีแก้ปัญหาที่ไม่ดีอีกมากมาย ฉันจะพยายามทำให้โพสต์นี้ค่อนข้างเป็นกลางโดยให้เกณฑ์ที่สมเหตุสมผลว่าตัวแปรควรทำงานอย่างไร และแสดงให้เห็นว่าตัวเลือกที่ไม่ดีนั้นไม่ดีอย่างไร
หวังว่าจะไม่เป็นข้อโต้แย้งว่าการออกแบบตัวแปรของภาษาโปรแกรมควรเป็น:
- ปลอดภัย. นั่นคือหลีกเลี่ยงปืนสั้น
- คาดเดาได้ นั่นคือหลีกเลี่ยงความสับสน
การออกแบบตัวแปรต้องตอบคำถามต่อไปนี้เป็นอย่างน้อย:
- (ก) ชนิดของเงาที่จะอนุญาต
- (ข) ไม่ว่าจะอนุญาตให้มอบหมายงานใหม่หรือไม่
- (ค). ไม่ว่าจะแยกการประกาศจากการใช้งานหรือไม่
ฉันจะอ่าน A-through-C แล้วสรุป TLDR คือวิธีที่ OCaml และภาษาที่คล้ายกันทำนั้นเป็นสิ่งที่ดีที่สุดที่มีการค้นพบมาจนถึงตอนนี้
ตอบ: การแชโดว์ประเภทใดที่อนุญาต
การแชโดว์คือเมื่อมีการแนะนำตัวแปรใหม่ st. ตัวแปรก่อนหน้าที่มีชื่อเดียวกันจะไม่ปรากฏให้เห็นอีกต่อไป ต่อไปนี้เป็นสองตัวอย่างของการแชโดว์:
// javascript let x = true; function foo() { let x = 3; // the first `x` is not accessible here } foo(); console.log(x) // true (* ocaml *) let () = let x = true in let x = 3 in (* the first `x` is not accessible here *) x
เป็นเรื่องยากที่จะพบภาษาที่มีตัวแปรที่ไม่อนุญาตให้มีการแชโดว์บางรูปแบบ ในกรณีที่ภาษาต่างกันคือที่ที่อนุญาตให้มีแชโดว์ได้
หากเราย้ายตัวอย่าง OCaml ไปยัง JavaScript เราได้รับข้อผิดพลาด:
// javascript let x = true; let x = 3; // SyntaxError: redeclaration of let x
การไม่อนุญาตให้มีแชโดว์ทุกที่มีความปลอดภัยน้อยกว่าการแชโดว์ ปัญหาจะเกิดขึ้นมากที่สุดเมื่อทำ "การอัปเดตการทำงาน" ซึ่งสามารถสร้างตัวแปรระดับกลางได้จำนวนมาก เป็นเรื่องปกติที่จะเห็นโค้ด Erlang ในลักษณะนี้:
% erlang example() -> State1 = init(), State2 = foo(State1), State3 = bar(State2), io:format("State3 is ~s~n", State3), State2. % implicit return. The author probably meant to return `State2`
เป็นเรื่องง่ายมากที่จะอ้างอิงผิด State
โดยไม่ได้ตั้งใจ ฉันเคยเห็นข้อผิดพลาดประเภทนี้ทั้งใน Erlang และ Scala
การแชโดว์ช่วยเพิ่มความปลอดภัยเนื่องจากตัวแปรที่คุณไม่สนใจอีกต่อไปจะไม่สามารถเข้าถึงได้ ดังนั้นจึงไม่มีอันตรายจากการใช้งานโดยไม่ตั้งใจ นี่คือตัวอย่างที่ย้ายไปยัง OCaml โดยตรง:
(* ocaml, but not very idiomatic *) let example () = let state = State.init () in let state = foo state in let state = bar state in Printf.printf "state is %s\n" (State.show state); state
การปล่อยให้แชโดว์ไปทุกที่ดูเหมือนจะดีกว่า w.r.t. ความปลอดภัย.
B: จะอนุญาตให้มีการโอนย้ายหรือไม่
ในบางภาษา การกำหนดใหม่ดูเหมือนการแรเงา ดังนั้นก่อนอื่นฉันจะอธิบายความแตกต่างก่อน
ในที่นี้ x
ในลูปแตกต่างจาก x
นอกลูป นี่คือเงา:
// javascript let x = 1; for (const i of [1, 2, 3]) { let x = 20 + i; } console.log(x); // 1
ที่นี่การวนซ้ำจะกำหนดใหม่ให้กับ x
ที่อยู่นอกลูป นี่คือการมอบหมายใหม่:
// javascript let x = 1; for (const i of [1, 2, 3]) { x = 20 + i; } console.log(x); // 23
การมอบหมายใหม่เป็นเรื่องละเอียดอ่อน เป็นการเปลี่ยนแปลงการแมปชื่อกับค่าต่างๆ การมอบหมายงานใหม่ด้วยความเข้าใจผิดเป็นเรื่องปกติที่น่าประหลาดใจ:
- มันนำไปสู่ภาพลวงตาว่าภาษาที่ไม่มีการอ้างอิงผ่านมีการอ้างอิงผ่าน ดูบทความ "Geeks For Geeks เกี่ยวกับความแตกต่างที่ลวงตา" และ "คำอธิบายของฉันเกี่ยวกับภาพลวงตา"
- หลายภาษามีทั้งตัวแปรที่สามารถมอบหมายใหม่และไม่สามารถมอบหมายใหม่ได้: JS
let
/const
, Swiftvar
/let
, Scala และ Kotlinvar
/val
, Rustlet mut
/let
ผู้ใช้ภาษาดังกล่าวมักจะสับสนระหว่างความไม่แน่นอนของการผูกตัวแปรกับความไม่แน่นอนของค่าที่ตัวแปรชี้ไป (ดู "บทความของ Dan Abramov")
หากภาษามีค่าที่ไม่แน่นอนอยู่แล้ว จำเป็นต้อง มีการแมปชื่อกับค่าที่ไม่แน่นอนภายในขอบเขตด้วย แนวคิดเดียวก็เพียงพอแล้วและลดความสับสนลงมาก นี่คือตัวอย่าง JS สองตัวอย่างด้านบนที่ย้ายไปยัง OCaml:
(* ocaml: shadowing *) let () = let x = 1 in for i = 1 to 3 do let x = 20 + i in let _ = x in (* avoid unused variable warning *) () done; print_int x (* prints 1 *)
เมื่อเปรียบเทียบกับตัวอย่างแชโดว์เวอร์ชัน JS จะเห็นได้ชัดกว่ามากว่าเกิดอะไรขึ้นใน OCaml เนื่องจากไม่มีการกำหนดใหม่
ต่อไปนี้คือวิธีทำให้การมอบหมายงานใหม่มีความชัดเจนและสะดวกเหมือนเดิมโดยไม่ต้องใช้ปืนยาว:
(* ocaml: references *) let () = let x = ref 1 in for i = 1 to 3 do x := 20 + i; done; print_int !x (* prints 23. `!` is the dereferencing operator *)
การอ้างอิง OCaml ไม่ใช่กรณีพิเศษ แต่เป็นเพียงบันทึกที่ไม่แน่นอนเท่านั้น การเปิดเผยคำจำกัดความของ :=
และ !
ทำให้เรา:
(* ocaml: desugared references *) type 'a ref = { mutable contents: 'a } let ref a = { contents = a } let () = let x = ref 1 in for i = 1 to 3 do x.contents <- 20 + i done; print_int x.contents (* prints 23 *)
Refs
มีประโยชน์มากจนปรากฏเป็นภาษาที่มีการมอบหมายใหม่ ตัวอย่างเช่น (แฮ็ค) และ (จาวาสคริปต์)
ไม่มีการมอบหมายงานใหม่จะดีกว่า หลีกเลี่ยงความสับสน ยังง่ายกว่า: เราสามารถแสดงออกแบบเดียวกันได้โดยใช้แนวคิดน้อยลง
C: จะแยกแยะการประกาศจากการใช้งานหรือไม่
การประกาศตัวแปรจะสร้างตัวแปรใหม่ในขอบเขต การใช้ตัวแปรรวมถึงการอ้างอิงตัวแปรและการประเมินใหม่ หากภาษาอนุญาตให้มีการกำหนดใหม่ได้
ความแตกต่างนี้เกิดขึ้น "ฟรี" หากภาษาไม่มีการกำหนดใหม่และไม่อนุญาตให้อ้างอิงตัวแปรที่ไม่ได้ประกาศ ดังนั้นหากคุณกำลังออกแบบภาษาและได้เลือกตัวเลือกที่ถูกต้องสำหรับ (B) แล้ว คุณสามารถหยุดได้ที่นี่
การไม่แยกการประกาศออกจากการใช้งานถือเป็นอันตรายและน่าสับสน ฉันจะอธิบายตามตัวอย่าง สำหรับแต่ละโปรแกรม Python ต่อไปนี้ ให้ลองค้นหาว่าโปรแกรมใดถูกพิมพ์ออกมา ฉันจะให้คำตอบด้านล่าง
v = [1] def foo(): v.append(2) v = [] foo() print(v) # python q1: what is printed? def foo(): x = 1 def bar(): x = 2 bar() print(x) # python q2: what is printed? foo() def foo(): x = 1 def bar(): nonlocal x x = 2 bar() print(x) # python q3: what is printed? foo() x = 1 def bar(): nonlocal x x = 2 bar() print(x) # python q4: what is printed? from random import choice if choice([True, False]): x = 3 print(x) # python q5: what is printed? from random import choice # adapted from https://twitter.com/elfprince13/status/1572944786763530241 def main(): e = None while e is None: try: if choice([True, False]): raise Exception("") except Exception as e: pass main() x = 3 print(x) # python q6: what is printed?
นี่คือคำตอบที่ทดสอบโดยใช้ Python 3.8.9:
- คำถามที่ 1: ไม่มีอะไร มี
UnboundLocalError
แม้ว่าโดยปกติ Python จะมีพฤติกรรมเหมือนล่ามทีละบรรทัด แต่v = []
ในบรรทัดสุดท้ายของฟังก์ชันก็มีผลในการย้อนเวลากลับไปและเปลี่ยนความหมายของบรรทัดแรกของฟังก์ชัน - q2: “1”
- q3: “2”
- คำถามที่ 4: ไม่มีอะไร มี
SyntaxError
: 'ไม่พบผลผูกพันสำหรับ nonlocal x' การย้ายโค้ดระหว่างขอบเขตฟังก์ชันที่ซ้อนกันและขอบเขตฟังก์ชันระดับบนสุดอาจทำให้เกิดข้อผิดพลาดรันไทม์ได้ หากคุณลืมเปลี่ยนnonlocal
เป็นglobal
- Q5: “5” หรือ
NameError
ขึ้นอยู่กับโชคของคุณ - คำถามที่ 6: ไม่มีอะไร มีการอ้างอิง
UnboundLocalError
:e
ก่อนมอบหมายงาน
การไม่แยกการประกาศออกจากการใช้งานไม่ได้หมายความถึงพฤติกรรมแปลกๆ แบบเดียวกับใน Python เสียทีเดียว แต่ที่แย่กว่านั้นคือ กฎเกณฑ์ว่าอะไรคืองานและอะไรคืองานมอบหมายใหม่ที่แตกต่างกันมากในแต่ละภาษาที่ไม่สามารถแยกแยะความแตกต่างนี้ได้
การแยกแยะระหว่างการประกาศและการใช้งานดีกว่าการรวมการประกาศและการใช้งานเพื่อความปลอดภัยและการคาดการณ์ได้
บทสรุป
เป็นการดีที่สุดสำหรับภาษาการเขียนโปรแกรมเพื่อ:
- อนุญาตให้ตัวแปรแชโดว์ได้อย่างอิสระ
- ไม่อนุญาตให้กำหนดใหม่ให้กับตัวแปร การแชโดว์และผู้อ้างอิงทำให้งานสำเร็จโดยใช้แนวคิดน้อยลงและมีความชัดเจนมากขึ้น
- แยกคำประกาศออกจากการใช้งาน สิ่งนี้มาฟรีหากคุณไม่มีการมอบหมายงานใหม่