ขั้นตอนทั่วไปที่ใช้เมื่อคอมไพเลอร์คืออะไรพิมพ์แบบตรวจสอบ "ซับซ้อน" นิพจน์?


23

หมายเหตุ:เมื่อฉันใช้ "ซับซ้อน" ในชื่อเรื่องฉันหมายถึงนิพจน์มีตัวดำเนินการและตัวถูกดำเนินการจำนวนมาก ไม่ใช่ว่าการแสดงออกของตัวเองนั้นซับซ้อน


ฉันเพิ่งทำงานกับคอมไพเลอร์เรียบง่ายเพื่อประกอบ x86-64 ฉันได้เสร็จสิ้นการรวบรวมส่วนหน้าหลักของคอมไพเลอร์ - lexer และ parser - และตอนนี้ฉันสามารถสร้างการแสดงบทคัดย่อต้นไม้ไวยากรณ์ของโปรแกรมของฉัน และเนื่องจากภาษาของฉันจะถูกพิมพ์แบบสแตติกฉันกำลังทำขั้นตอนต่อไป: พิมพ์การตรวจสอบซอร์สโค้ด อย่างไรก็ตามฉันมีปัญหาและไม่สามารถแก้ไขด้วยตนเองได้อย่างสมเหตุสมผล

ลองพิจารณาตัวอย่างต่อไปนี้:

โปรแกรมแยกวิเคราะห์ของคอมไพเลอร์ของฉันได้อ่านบรรทัดของรหัสนี้:

int a = 1 + 2 - 3 * 4 - 5

และแปลงเป็น AST ต่อไปนี้:

       =
     /   \
  a(int)  \
           -
         /   \
        -     5
      /   \
     +     *
    / \   / \
   1   2 3   4

ตอนนี้จะต้องพิมพ์ตรวจสอบ AST มันเริ่มต้นด้วยการตรวจสอบ=ผู้ประกอบการก่อน มันจะตรวจสอบด้านซ้ายมือของผู้ปฏิบัติงานก่อน จะเห็นว่าตัวแปรaถูกประกาศเป็นจำนวนเต็ม ดังนั้นตอนนี้ต้องตรวจสอบว่านิพจน์ทางด้านขวาประเมินเป็นจำนวนเต็ม

ผมเข้าใจวิธีนี้สามารถทำได้ถ้าการแสดงออกเป็นเพียงค่าเดียวเช่นหรือ1 'a'แต่สิ่งนี้จะเกิดขึ้นได้อย่างไรสำหรับนิพจน์ที่มีค่าและตัวถูกดำเนินการหลายค่าซึ่งเป็นนิพจน์ที่ซับซ้อนเช่นที่กล่าวข้างต้น เมื่อต้องการกำหนดค่าของนิพจน์อย่างถูกต้องดูเหมือนว่าตัวตรวจสอบชนิดจะเกิดขึ้นจริงต้องดำเนินการแสดงออกและบันทึกผลลัพธ์ แต่นี่ดูเหมือนจะเอาชนะจุดประสงค์ของการแยกขั้นตอนการรวบรวมและการดำเนินการอย่างชัดเจน

วิธีเดียวที่ฉันคิดว่าสิ่งนี้สามารถทำได้คือการตรวจสอบใบไม้ซ้ำของแต่ละนิพจน์ย่อยใน AST และตรวจสอบว่าชนิดของใบไม้ทั้งหมดตรงกับประเภทของผู้ปฏิบัติงานที่คาดหวัง ดังนั้นเริ่มต้นจาก=โอเปอเรเตอร์ตัวตรวจสอบชนิดจะสแกน AST ทางด้านซ้ายทั้งหมดและตรวจสอบว่าลีฟเป็นจำนวนเต็มทั้งหมด จากนั้นจะทำซ้ำสิ่งนี้สำหรับแต่ละโอเปอเรเตอร์ในนิพจน์ย่อย

ฉันได้ลองค้นคว้าหัวข้อในสำเนา"The Dragon Book" ของฉันแล้ว แต่ดูเหมือนจะไม่ได้ลงรายละเอียดมากนักและเพียงแค่ย้ำสิ่งที่ฉันรู้แล้ว

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


8
มีวิธีที่ชัดเจนและเรียบง่ายในการตรวจสอบประเภทของนิพจน์ คุณควรบอกเราว่าอะไรทำให้คุณเรียกว่า "น่ารังเกียจ"
gnasher729

12
วิธีการปกติคือ "วิธีที่สอง": คอมไพเลอร์ infers ประเภทของการแสดงออกที่ซับซ้อนจากประเภทของนิพจน์ย่อยของมัน นั่นคือประเด็นหลักของความหมายเชิง denotational และระบบพิมพ์ส่วนใหญ่ที่สร้างขึ้นมาจนถึงทุกวันนี้
Joker_vD

5
วิธีการทั้งสองอาจทำให้เกิดพฤติกรรมที่แตกต่าง: วิธีการจากบนลงล่างdouble a = 7/2 จะพยายามตีความทางด้านขวาเป็นสองเท่าดังนั้นจะพยายามตีความตัวเศษและส่วนเป็นสองเท่าและแปลงหากจำเป็น a = 3.5ผลที่ตามมา ด้านล่างขึ้นจะดำเนินการส่วนจำนวนเต็มและแปลงเฉพาะเมื่อขั้นตอนสุดท้าย (ที่ได้รับมอบหมาย) a = 3.0ดังนั้น
Hagen von Eitzen

3
โปรดสังเกตว่ารูปภาพของ AST ของคุณไม่ตรงกับการแสดงออกของคุณint a = 1 + 2 - 3 * 4 - 5แต่ไปที่int a = 5 - ((4*3) - (1+2))
Basile Starynkevitch

22
คุณสามารถ "ดำเนินการ" การแสดงออกในประเภทมากกว่าค่า; เช่นจะกลายเป็นint + int int

คำตอบ:


14

การเรียกซ้ำเป็นคำตอบ แต่คุณสืบสู่แต่ละทรีย่อยก่อนที่จะจัดการการดำเนินการ:

int a = 1 + 2 - 3 * 4 - 5

เป็นรูปแบบต้นไม้:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

การอนุมานประเภทเกิดขึ้นโดยการเดินทางด้านซ้ายก่อนจากนั้นทางด้านขวามือจากนั้นจัดการผู้ปฏิบัติงานทันทีที่ประเภทของตัวถูกดำเนินการถูกอนุมาน:

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> ลงไปสู่ ​​lhs

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> aสรุป เป็นที่รู้จักกันa intเรากลับมาที่assignโหนดทันที:

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> สืบเชื้อสายมาจาก rhs จากนั้นเข้าสู่ lhs ของผู้ประกอบการด้านในจนกว่าเราจะตีสิ่งที่น่าสนใจ

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

-> สรุปประเภทของ1ซึ่งคือintและกลับไปที่พาเรนต์

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

-> เข้าไปใน rhs

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

-> สรุปประเภทของ2ซึ่งคือintและกลับไปที่พาเรนต์

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

-> สรุปประเภทของadd(int, int)ซึ่งคือintและกลับไปที่พาเรนต์

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

-> สืบเชื้อสายมาจาก rhs

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

เป็นต้นจนกว่าคุณจะจบลงด้วย

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

การกำหนดว่าตัวเองเป็นนิพจน์ที่มีประเภทขึ้นอยู่กับภาษาของคุณหรือไม่

Takeaway ที่สำคัญ: เพื่อกำหนดประเภทของโหนดโอเปอเรเตอร์ใด ๆ ในทรีคุณจะต้องดูที่ลูกของมันทันทีซึ่งต้องมีประเภทที่กำหนดให้กับพวกเขาแล้ว


43

อะไรคือวิธีการที่ใช้เมื่อคอมไพเลอร์คือการตรวจสอบประเภทของนิพจน์ที่มีตัวดำเนินการและตัวถูกดำเนินการมากมาย

อ่าน wikipages ในระบบการพิมพ์และการอนุมานชนิดและระบบการพิมพ์ Hindley-มิลเนอร์ซึ่งใช้การผสมผสาน อ่านยังเกี่ยวกับความหมาย denotationalและความหมายในการดำเนินงาน

การตรวจสอบประเภทสามารถทำได้ง่ายกว่าหาก:

  • ตัวแปรทั้งหมดของคุณaจะถูกประกาศพร้อมกับประเภทอย่างชัดเจน นี้เป็นเหมือน C หรือปาสคาลหรือ C ++ 98 แต่ไม่ได้เช่น C ++ 11 autoซึ่งมีบางชนิดที่มีการอนุมาน
  • ค่าตัวอักษรทั้งหมดเช่น1, 2หรือ'c'มีประเภทโดยธรรมชาติ: ตัวอักษร int มักจะมีชนิดintตัวอักษรตัวอักษรมักจะมีประเภทchar, ...
  • ฟังก์ชั่นและผู้ประกอบการไม่ได้รับภาระมากเกินไปเช่น+ผู้ประกอบการมีประเภท(int, int) -> intเสมอ C มีการบรรทุกเกินพิกัดสำหรับโอเปอเรเตอร์ ( +ทำงานสำหรับประเภทจำนวนเต็มที่ลงนามและไม่ได้ลงนามและเพื่อเพิ่มเป็นสองเท่า) แต่ไม่มีการโอเวอร์โหลดของฟังก์ชัน

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

  • สำหรับแต่ละขอบเขตคุณเก็บตารางสำหรับประเภทของตัวแปรที่มองเห็นได้ทั้งหมด (เรียกว่าสภาพแวดล้อม) หลังจากประกาศint aคุณจะเพิ่มรายการa: intลงในตาราง

  • การพิมพ์ใบเป็นกรณีพื้นฐานแบบเรียกซ้ำ: ประเภทของตัวอักษรที่1เป็นที่รู้จักกันอยู่แล้วและประเภทของตัวแปรเช่นaสามารถค้นหาได้ในสภาพแวดล้อม

  • ในการพิมพ์นิพจน์ด้วยโอเปอเรเตอร์และโอเปอแรนด์ตามประเภทที่คำนวณก่อนหน้านี้ของโอเปอเรเตอร์ (นิพจน์ย่อยที่ซ้อนกัน) เราใช้การเรียกซ้ำบนโอเปอแรนด์ (เพื่อให้เราพิมพ์นิพจน์ย่อยเหล่านี้) .

ดังนั้นในตัวอย่างของคุณ 4 * 3และ1 + 2มีการพิมพ์intเพราะ4และ3และ1และ2ได้รับการพิมพ์ก่อนหน้านี้intและกฎระเบียบการพิมพ์ของคุณบอกว่าผลรวมหรือผลิตภัณฑ์ของทั้งสองint-s เป็นintและอื่น ๆ (4 * 3) - (1 + 2)สำหรับ

แล้วอ่านเพียร์ซของประเภทและการเขียนโปรแกรมภาษาหนังสือ ฉันแนะนำให้เรียนรู้Ocamlเล็กน้อยและcalcul- แคลคูลัส

สำหรับภาษาที่พิมพ์แบบไดนามิกมากขึ้น (เช่นเสียงกระเพื่อม) โปรดอ่านLisp In Small Piecesของ Queinnec ด้วย

อ่านหนังสือของPragmatics ภาษาสกอตต์ด้วย

BTW, คุณไม่สามารถมีรหัสภาษาที่ไม่เชื่อเรื่องพระเจ้าพิมพ์เพราะระบบการพิมพ์เป็นส่วนสำคัญของภาษาของความหมาย


2
C ++ 11 autoไม่ง่ายกว่านี้อย่างไร? หากไม่มีคุณต้องคิดประเภททางด้านขวาจากนั้นดูว่ามีการจับคู่หรือการแปลงด้วยประเภททางด้านซ้ายหรือไม่ กับautoคุณเพียงแค่คิดประเภทของด้านขวาและคุณเสร็จแล้ว
nwp

3
@nwp แนวคิดทั่วไปของคำนิยามตัวแปรC ++ auto, C # varและ Go :=นั้นง่ายมาก: พิมพ์เครื่องหมายทางด้านขวามือของคำจำกัดความ ประเภทผลลัพธ์เป็นชนิดของตัวแปรทางด้านซ้ายมือ แต่มารอยู่ในรายละเอียด ตัวอย่างเช่น C ++ คำจำกัดความสามารถเป็นตัวอ้างอิงดังนั้นคุณอาจหมายถึงตัวแปรที่ถูกประกาศ RHS int i = f(&i)เช่น ถ้าชนิดของiเหมาเอาขั้นตอนวิธีการดังกล่าวข้างต้นจะล้มเหลว: คุณจำเป็นต้องรู้ประเภทของเพื่อสรุปประเภทของi iแต่คุณต้องมีการอนุมานประเภท HM-style แบบเต็มพร้อมกับตัวแปร type
amon

13

ใน C (และภาษาที่พิมพ์แบบคงที่ส่วนใหญ่ตาม C) ผู้ปฏิบัติงานทุกคนสามารถมองเห็นเป็นน้ำตาล syntactic สำหรับการเรียกใช้ฟังก์ชั่น

ดังนั้นการแสดงออกของคุณสามารถเขียนใหม่เป็น:

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

จากนั้นความละเอียดที่มากเกินไปจะเริ่มขึ้นและตัดสินว่าทุกฟังก์ชั่นเป็นของ(int, int)หรือ(const int&, const int&)ประเภท

วิธีนี้ทำให้การจำแนกประเภทง่ายต่อการเข้าใจและติดตามและ (สำคัญกว่า) ใช้งานง่าย ข้อมูลเกี่ยวกับประเภทจะไหลใน 1 ทางเท่านั้น (จากนิพจน์ภายในออกไปด้านนอก)

นั่นคือเหตุผลที่double x = 1/2;จะให้ผลลัพธ์x == 0เพราะ1/2ถูกประเมินว่าเป็นนิพจน์ int


6
เกือบเป็นจริงสำหรับ C ซึ่ง+ไม่ได้รับการจัดการเหมือนการเรียกใช้ฟังก์ชั่น (เนื่องจากมีการพิมพ์doubleและสำหรับintตัวถูกดำเนินการต่าง ๆ )
Basile Starynkevitch

2
@BasileStarynkevitch: มันดำเนินการเหมือนชุดของฟังก์ชั่นมากเกินไป A: operator+(int,int), operator+(double,double), operator+(char*,size_t)ฯลฯ parser เพียงแค่มีการติดตามที่หนึ่งที่ถูกเลือก
Mooing Duck

3
ไม่มีใคร @aschepler ได้รับการบอกว่าที่แหล่งที่มาและข้อมูลจำเพาะระดับ C จริงได้มากเกินไปฟังก์ชั่นการทำงานหรือผู้ประกอบการ
แมว

1
ไม่แน่นอน เพียงชี้ให้เห็นว่าในกรณีของตัวแยกวิเคราะห์ C "การเรียกใช้ฟังก์ชัน" เป็นอย่างอื่นที่คุณต้องจัดการนั่นไม่ได้มีอะไรเหมือนกันกับ "ตัวดำเนินการตามหน้าที่" ตามที่อธิบายไว้ที่นี่ ในความเป็นจริงใน C การหาชนิดของค่อนข้างบิตง่ายกว่าการหาชนิดของf(a,b) a+b
aschepler

2
คอมไพเลอร์ C ที่เหมาะสมมีหลายเฟส ใกล้ด้านหน้า (หลังตัวประมวลผลล่วงหน้า) คุณจะพบ parser ซึ่งสร้าง AST ขึ้น ที่นี่ค่อนข้างชัดเจนว่าผู้ประกอบการไม่ได้เรียกฟังก์ชั่น แต่ในการสร้างรหัสคุณไม่สนใจว่าโครงสร้างภาษาใดที่สร้างโหนด AST คุณสมบัติของโหนดจะกำหนดวิธีการจัดการกับโหนด โดยเฉพาะอย่างยิ่ง + อาจเป็นการเรียกใช้ฟังก์ชันเป็นอย่างดีซึ่งมักเกิดขึ้นบนแพลตฟอร์มที่มีเลขทศนิยมที่จำลองขึ้นมา การตัดสินใจใช้ FP ทางคณิตศาสตร์แบบจำลองเกิดขึ้นในการสร้างรหัส ไม่มีความแตกต่างของ AST ก่อนหน้านี้ที่จำเป็น
MSalters

6

มุ่งเน้นไปที่อัลกอริทึมของคุณลองเปลี่ยนเป็นจากล่างขึ้นบน คุณรู้จักตัวแปร pf และค่าคงที่ แท็กโหนดที่มีตัวดำเนินการพร้อมกับชนิดผลลัพธ์ ปล่อยให้ใบไม้เป็นตัวกำหนดประเภทของผู้ปฏิบัติงานซึ่งตรงข้ามกับความคิดของคุณ


6

จริงๆแล้วมันค่อนข้างง่ายตราบใดที่คุณคิดว่า+เป็นฟังก์ชั่นที่หลากหลายมากกว่าแนวคิดเดียว

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

ในระหว่างขั้นตอนการแยกวิเคราะห์ของด้านขวามือตัวแยกวิเคราะห์ดึง1รู้ว่าเป็นintแล้วแยกวิเคราะห์+และเก็บที่เป็น "ชื่อฟังก์ชั่นที่ไม่ได้แก้ไข" แล้วมันแยกวิเคราะห์2รู้ว่าเป็นintแล้วส่งกลับที่สแต็ก +โหนดฟังก์ชั่นตอนนี้รู้ทั้งสองชนิดพารามิเตอร์เพื่อให้สามารถแก้ไข+เข้าint operator+(int, int)ดังนั้นตอนนี้มันรู้ประเภทของการย่อยนี้การแสดงออกและ parser ยังคงเป็นของทางม้า

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

char* ptr = itoa(3);

ที่นี่ต้นไม้คือ:

    char* itoa(int)
     /           \
  ptr(char*)      3

4

พื้นฐานสำหรับการตรวจสอบชนิดไม่ใช่สิ่งที่คอมไพเลอร์ทำ แต่เป็นสิ่งที่ภาษากำหนด

ในภาษา C ตัวถูกดำเนินการทุกตัวมีประเภท "abc" มีประเภท "array ของ const char" 1 มีประเภท "int" 1L มีประเภท "ยาว" ถ้า x และ y เป็นนิพจน์แสดงว่ามีกฎสำหรับประเภทของ x + y และอื่น ๆ ดังนั้นผู้แปลจึงต้องปฏิบัติตามกฎของภาษา

สำหรับภาษาสมัยใหม่อย่าง Swift กฎนั้นซับซ้อนกว่ามาก บางกรณีเป็นเรื่องง่ายเหมือนในซีกรณีอื่นคอมไพเลอร์เห็นนิพจน์ได้รับการบอกกล่าวล่วงหน้าว่าควรมีนิพจน์ประเภทใดและกำหนดประเภทของนิพจน์ย่อยตามนั้น หาก x และ y เป็นตัวแปรประเภทต่าง ๆ และมีการกำหนดนิพจน์เหมือนกันนิพจน์นั้นอาจได้รับการประเมินในวิธีที่ต่างกัน ตัวอย่างเช่นการกำหนด 12 * (2/3) จะกำหนด 8.0 ให้แก่ Double และ 0 ถึง Int และคุณมีกรณีที่คอมไพเลอร์รู้ว่ามีสองประเภทที่เกี่ยวข้องและพิจารณาว่าเป็นประเภทใด

ตัวอย่างรวดเร็ว:

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

พิมพ์ "8.0, 0"

ในการกำหนด x = 12 * (2/3): ด้านซ้ายมือมีประเภท Double ที่รู้จักกันดังนั้นด้านขวามือจะต้องมีประเภท Double มีเพียงโอเวอร์โหลดเดียวสำหรับผู้ประกอบการ "*" ที่ส่งคืน Double และนั่นคือ Double * Double -> Double ดังนั้น 12 จะต้องมีประเภท Double และ 2/3 12 รองรับโปรโตคอล "IntegerLiteralConvertible" Double มีผู้เริ่มต้นใช้อาร์กิวเมนต์ประเภท "IntegerLiteralConvertible" ดังนั้น 12 จะถูกแปลงเป็น Double 2/3 ต้องมีประเภท Double มีเพียงโอเวอร์โหลดเดียวสำหรับผู้ประกอบการ "/" ที่ส่งคืน Double และนั่นคือ Double / Double -> Double 2 และ 3 ถูกแปลงเป็น Double ผลลัพธ์ของ 2/3 คือ 0.6666666 ผลลัพธ์ของ 12 * (2/3) คือ 8.0 8.0 ถูกกำหนดให้กับ x

ในการมอบหมาย y = 12 * (2/3) y บนด้านซ้ายมือมีประเภท Int ดังนั้นด้านขวามือต้องมีประเภท Int ดังนั้น 12, 2, 3 จะถูกแปลงเป็น Int พร้อมผลลัพธ์ 2/3 = 0, 12 * (2/3) = 0

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.