พื้นที่การออกแบบสำหรับตัวแปรในภาษาการเขียนโปรแกรมมีขนาดใหญ่ แต่จนถึงขณะนี้ (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, Swift var/let, Scala และ Kotlin var/val, Rust let 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 เสียทีเดียว แต่ที่แย่กว่านั้นคือ กฎเกณฑ์ว่าอะไรคืองานและอะไรคืองานมอบหมายใหม่ที่แตกต่างกันมากในแต่ละภาษาที่ไม่สามารถแยกแยะความแตกต่างนี้ได้

การแยกแยะระหว่างการประกาศและการใช้งานดีกว่าการรวมการประกาศและการใช้งานเพื่อความปลอดภัยและการคาดการณ์ได้

บทสรุป

เป็นการดีที่สุดสำหรับภาษาการเขียนโปรแกรมเพื่อ:

  • อนุญาตให้ตัวแปรแชโดว์ได้อย่างอิสระ
  • ไม่อนุญาตให้กำหนดใหม่ให้กับตัวแปร การแชโดว์และผู้อ้างอิงทำให้งานสำเร็จโดยใช้แนวคิดน้อยลงและมีความชัดเจนมากขึ้น
  • แยกคำประกาศออกจากการใช้งาน สิ่งนี้มาฟรีหากคุณไม่มีการมอบหมายงานใหม่