ทำให้มือของคุณสกปรกด้วย Scala JVM Bytecode

เผยแพร่แล้ว: 2022-03-11

ภาษา Scala ได้รับความนิยมอย่างต่อเนื่องในช่วงหลายปีที่ผ่านมา ต้องขอบคุณการผสมผสานที่ยอดเยี่ยมของหลักการพัฒนาซอฟต์แวร์เชิงฟังก์ชันและเชิงวัตถุ และการใช้งานบน Java Virtual Machine (JVM) ที่ได้รับการพิสูจน์แล้ว

แม้ว่า Scala จะคอมไพล์เป็น Java bytecode แต่ก็ได้รับการออกแบบมาเพื่อปรับปรุงข้อบกพร่องหลายอย่างของภาษา Java ซินแทกซ์หลักของ Scala นำเสนอการสนับสนุนการเขียนโปรแกรมที่ทำงานได้อย่างเต็มรูปแบบ ประกอบด้วยโครงสร้างโดยนัยจำนวนมากที่ต้องสร้างขึ้นอย่างชัดเจนโดยโปรแกรมเมอร์ Java ซึ่งบางส่วนเกี่ยวข้องกับความซับซ้อนอย่างมาก

การสร้างภาษาที่คอมไพล์เป็น Java bytecode จำเป็นต้องมีความเข้าใจอย่างลึกซึ้งเกี่ยวกับการทำงานภายในของ Java Virtual Machine เพื่อชื่นชมสิ่งที่นักพัฒนาของ Scala ทำได้สำเร็จ จำเป็นต้องอยู่ภายใต้ประทุนและสำรวจว่าคอมไพเลอร์ตีความซอร์สโค้ดของ Scala อย่างไรเพื่อสร้าง JVM bytecode ที่มีประสิทธิภาพและประสิทธิผล

มาดูกันว่าสิ่งเหล่านี้ถูกนำไปใช้อย่างไร

ข้อกำหนดเบื้องต้น

การอ่านบทความนี้จำเป็นต้องมีความเข้าใจพื้นฐานของ Java Virtual Machine bytecode สามารถรับข้อมูลจำเพาะเครื่องเสมือนที่สมบูรณ์ได้จากเอกสารอย่างเป็นทางการของ Oracle การอ่านข้อมูลจำเพาะทั้งหมดไม่มีความสำคัญต่อการทำความเข้าใจบทความนี้ ดังนั้น สำหรับข้อมูลเบื้องต้นอย่างรวดเร็วเกี่ยวกับพื้นฐาน ฉันได้เตรียมคำแนะนำสั้น ๆ ไว้ที่ด้านล่างของบทความ

คลิกที่นี่เพื่ออ่านหลักสูตรความผิดพลาดเกี่ยวกับพื้นฐานของ JVM

ยูทิลิตีจำเป็นต้องแยกส่วน Java bytecode เพื่อทำซ้ำตัวอย่างที่ให้ไว้ด้านล่าง และเพื่อดำเนินการตรวจสอบต่อไป Java Development Kit มียูทิลิตีบรรทัดคำสั่งของตัวเอง javap ซึ่งเราจะใช้ที่นี่ การสาธิตอย่างรวดเร็วเกี่ยวกับวิธีการทำงานของ javap จะรวมอยู่ในคำแนะนำที่ด้านล่าง

และแน่นอนว่าการติดตั้งคอมไพเลอร์ Scala ที่ใช้งานได้นั้นจำเป็นสำหรับผู้อ่านที่ต้องการติดตามพร้อมกับตัวอย่าง บทความนี้เขียนขึ้นโดยใช้ Scala 2.11.7 Scala เวอร์ชันต่างๆ อาจสร้าง bytecode ที่แตกต่างกันเล็กน้อย

Getters และ Setters เริ่มต้น

แม้ว่าแบบแผนของ Java จะจัดเตรียมวิธี getter และ setter สำหรับแอ็ตทริบิวต์สาธารณะเสมอ แต่โปรแกรมเมอร์ Java จำเป็นต้องเขียนสิ่งเหล่านี้เอง แม้ว่ารูปแบบสำหรับแต่ละแบบจะไม่เปลี่ยนแปลงในทศวรรษที่ผ่านมา ในทางตรงกันข้าม สกาล่ามีตัวรับและตัวตั้งค่าเริ่มต้น

ลองดูตัวอย่างต่อไปนี้:

 class Person(val name:String) { }

มาดูภายในคลาส Person กัน หากเราคอมไพล์ไฟล์นี้ด้วย scalac การรัน $ javap -p Person.class จะทำให้เรา:

 Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }

เราจะเห็นได้ว่าในแต่ละฟิลด์ในคลาส Scala จะมีการสร้างฟิลด์และวิธีการรับข้อมูล ฟิลด์นี้เป็นฟิลด์ส่วนตัวและสุดท้าย ในขณะที่เมธอดเป็นแบบสาธารณะ

หากเราแทนที่ val ด้วย var ในแหล่งข้อมูล Person และคอมไพล์ใหม่ ตัวแก้ไข final ของฟิลด์จะถูกละทิ้ง และวิธีการ setter จะถูกเพิ่มเข้าไปด้วยเช่นกัน:

 Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }

หากมีการกำหนด val หรือ var ภายในเนื้อหาของคลาส ฟิลด์ส่วนตัวที่เกี่ยวข้องและวิธีการเข้าถึงจะถูกสร้างขึ้น และเริ่มต้นอย่างเหมาะสมเมื่อสร้างอินสแตนซ์

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

นิยามตัวแปรและฟังก์ชัน

สมมติว่าเรามีเมธอด m() และสร้างการอ้างอิงสไตล์สกาลาที่แตกต่างกันสามแบบสำหรับฟังก์ชันนี้:

 class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }

การอ้างอิงถึง m แต่ละรายการเหล่านี้ถูกสร้างขึ้นมาอย่างไร? m จะถูกประหารชีวิตในแต่ละกรณีเมื่อใด? ลองดูที่ผลลัพธ์ของ bytecode เอาต์พุตต่อไปนี้แสดงผลของ javap -v Person.class (ละเว้นเอาต์พุตที่ไม่จำเป็นจำนวนมาก):

 Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return

ในกลุ่มคงที่ เราจะเห็นว่าการอ้างอิงถึงวิธี m() ถูกเก็บไว้ที่ดัชนี #30 ในโค้ดตัวสร้าง เราเห็นว่าเมธอดนี้ถูกเรียกใช้สองครั้งในระหว่างการเริ่มต้น โดยคำสั่ง invokevirtual #30 ปรากฏขึ้นครั้งแรกที่ byte offset 11 จากนั้นที่ offset 19 การเรียกใช้ครั้งแรกตามด้วยคำสั่ง putfield #22 ซึ่งกำหนดผลลัพธ์ของ เมธอดนี้ไปยังฟิลด์ m1 อ้างอิงโดยดัชนี #22 ในพูลคงที่ การเรียกใช้ครั้งที่สองตามด้วยรูปแบบเดียวกัน คราวนี้การกำหนดค่าให้กับฟิลด์ m2 จัดทำดัชนีที่ #24 ในพูลคงที่

กล่าวอีกนัยหนึ่ง การกำหนดวิธีการให้กับตัวแปรที่กำหนดด้วย val หรือ var จะกำหนด ผลลัพธ์ ของวิธีการให้กับตัวแปรนั้นเท่านั้น เราจะเห็นได้ว่าเมธอด m1() และ m2() ที่สร้างขึ้นเป็นเพียงตัวรับสำหรับตัวแปรเหล่านี้ ในกรณีของ var m2 เราจะเห็นว่าตัวตั้ง m2_$eq(int) ถูกสร้างขึ้น ซึ่งทำงานเหมือนกับตัวตั้งค่าอื่นๆ โดยจะเขียนทับค่าในฟิลด์

อย่างไรก็ตาม การใช้คีย์เวิร์ด def ให้ผลลัพธ์ที่ต่างออกไป แทนที่จะดึงค่าฟิลด์เพื่อส่งคืน เมธอด m3() ยังรวมคำสั่ง invokevirtual #30 ด้วย นั่นคือทุกครั้งที่เรียกเมธอดนี้ มันจะเรียก m() และส่งคืนผลลัพธ์ของเมธอดนี้

ดังที่เราเห็น Scala มีสามวิธีในการทำงานกับฟิลด์คลาส และสิ่งเหล่านี้สามารถระบุได้อย่างง่ายดายผ่านคำหลัก val , var , และ def ใน Java เราจะต้องติดตั้ง setter และ getter ที่จำเป็นอย่างชัดเจน และโค้ดสำเร็จรูปที่เขียนด้วยตนเองดังกล่าวจะมีความชัดเจนน้อยกว่าและมีโอกาสเกิดข้อผิดพลาดมากกว่า

ค่าขี้เกียจ

รหัสที่ซับซ้อนมากขึ้นถูกสร้างขึ้นเมื่อประกาศค่าสันหลังยาว สมมติว่าเราได้เพิ่มฟิลด์ต่อไปนี้ในคลาสที่กำหนดไว้ก่อนหน้านี้:

 lazy val m4 = m

การรัน javap -p -v Person.class จะเปิดเผยสิ่งต่อไปนี้:

 Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn

ในกรณีนี้ ค่าของฟิลด์ m4 จะไม่ถูกคำนวณจนกว่าจะมีความจำเป็น เมธอดส่วนตัวพิเศษ m4$lzycompute() ถูกสร้างเพื่อคำนวณค่าสันหลังยาว และฟิลด์ bitmap$0 เพื่อติดตามสถานะ เมธอด m4() ตรวจสอบว่าค่าของฟิลด์นี้เป็น 0 หรือไม่ ซึ่งบ่งชี้ว่า m4 ยังไม่ได้เริ่มต้น ซึ่งในกรณีนี้ m4$lzycompute() จะถูกเรียกใช้ โดยเติม m4 และคืนค่าของมัน เมธอดส่วนตัวนี้ยังตั้งค่า bitmap$0 เป็น 1 ดังนั้นในครั้งต่อไปที่เรียก m4() จะข้ามการเรียกใช้วิธีการเริ่มต้น และส่งกลับค่าของ m4 แทน

ผลลัพธ์ของการเรียกค่า Scala lazy ครั้งแรก

bytecode Scala สร้างขึ้นที่นี่ได้รับการออกแบบมาให้มีทั้งเธรดที่ปลอดภัยและมีประสิทธิภาพ เพื่อความปลอดภัยของเธรด วิธีคำนวณแบบสันหลังยาวจะใช้คู่ของคำสั่ง monitorenter / monitorexit เมธอดยังคงมีผลเนื่องจากโอเวอร์เฮดด้านประสิทธิภาพของการซิงโครไนซ์นี้จะเกิดขึ้นเฉพาะเมื่ออ่านค่าสันหลังในครั้งแรกเท่านั้น

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

อีกครั้ง Scala ช่วยให้เราสามารถใช้ประโยชน์จากพฤติกรรมเฉพาะประเภทที่จะต้องดำเนินการอย่างชัดแจ้งใน Java ช่วยประหยัดความพยายามและลดความเสี่ยงของการพิมพ์ผิด

ทำหน้าที่เป็นค่า

ทีนี้มาดูซอร์สโค้ดของ Scala ต่อไปนี้:

 class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }

คลาส Printer มีหนึ่งฟิลด์ output โดยมีประเภท String => Unit : ฟังก์ชันที่รับ String และส่งคืนอ็อบเจ็กต์ประเภท Unit (คล้ายกับ void ใน Java) ในวิธีการหลัก เราสร้างหนึ่งในวัตถุเหล่านี้ และกำหนดฟิลด์นี้เป็นฟังก์ชันที่ไม่ระบุชื่อที่พิมพ์สตริงที่กำหนด

การคอมไพล์โค้ดนี้จะสร้างไฟล์คลาสสี่ไฟล์:

ซอร์สโค้ดถูกคอมไพล์เป็นไฟล์คลาสสี่ไฟล์

Hello.class เป็นคลาส wrapper ซึ่งวิธีการหลักเรียกง่ายๆว่า Hello$.main() :

 public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return

Hello$.class ที่ซ่อนอยู่มีการนำไปใช้จริงของวิธีการหลัก ในการดู bytecode ของมัน ตรวจสอบให้แน่ใจว่าคุณใช้ Escape $ อย่างถูกต้องตามกฎของ command shell ของคุณ เพื่อหลีกเลี่ยงการตีความเป็นอักขระพิเศษ:

 public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return

วิธีการสร้าง Printer . จากนั้นจะสร้าง Hello$$anonfun$1 ซึ่งมีฟังก์ชันไม่ระบุชื่อของเรา s => println(s) Printer ถูกเตรียมใช้งานโดยวัตถุนี้เป็นฟิลด์ output ฟิลด์นี้จะถูกโหลดลงในสแต็ก และดำเนินการด้วยตัวถูกดำเนินการ "Hello"

มาดูคลาสฟังก์ชันที่ไม่ระบุชื่อ Hello$$anonfun$1.class ด้านล่าง เราจะเห็นได้ว่ามันขยาย Function1 ของ Scala (เป็น AbstractFunction1 ) โดยใช้วิธี apply() ที่จริงแล้ว มันสร้างเมธอด apply() สองวิธี วิธีหนึ่งปิดอีกวิธีหนึ่ง ซึ่งร่วมกันทำการตรวจสอบประเภท (ในกรณีนี้ อินพุตคือ String ) และเรียกใช้ฟังก์ชันที่ไม่ระบุตัวตน (พิมพ์อินพุตด้วย println() )

 public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn

เมื่อมองย้อนกลับไปที่เมธอด Hello$.main() ด้านบน เราจะเห็นว่า ที่ offset 21 การทำงานของฟังก์ชันที่ไม่ระบุชื่อจะถูกทริกเกอร์โดยการเรียกใช้เมธอด apply( Object )

สุดท้าย เพื่อความสมบูรณ์ มาดูที่ bytecode สำหรับ Printer.class :

 public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return

เราจะเห็นว่าฟังก์ชันที่ไม่ระบุชื่อที่นี่ได้รับการปฏิบัติเหมือนกับตัวแปร val ใดๆ มันถูกเก็บไว้ใน class field output และ getter output() จะถูกสร้างขึ้น ข้อแตกต่างเพียงอย่างเดียวคือตอนนี้ตัวแปรนี้ต้องใช้อินเทอร์เฟซ Scala scala.Function1 (ซึ่ง AbstractFunction1 ทำ)

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

ภายใต้ประทุนกับ Scala: สำรวจว่าภาษาที่มีประสิทธิภาพนี้ถูกนำมาใช้ใน JVM bytecode อย่างไร
ทวีต

ลักษณะสกาล่า

คุณสมบัติของ Scala นั้นคล้ายกับอินเตอร์เฟสใน Java ลักษณะต่อไปนี้กำหนดลายเซ็นของเมธอดสองรายการ และจัดเตรียมการใช้งานดีฟอลต์ของอันที่สอง มาดูกันว่ามันมีการใช้งานอย่างไร:

 trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) } 

ซอร์สโค้ดถูกคอมไพล์เป็นไฟล์คลาสสองไฟล์

มีการสร้างเอนทิตีสองรายการ: Similarity.class Similarity$class.class ที่ประกาศทั้งสองเมธอด และคลาสสังเคราะห์

 public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
 public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return

เมื่อคลาสใช้คุณสมบัตินี้และเรียกเมธอด isNotSimilar คอมไพเลอร์ Scala จะสร้างคำสั่ง invokestatic ที่เรียกใช้งานเพื่อเรียกเมธอดสแตติกที่คลาสที่มาพร้อมกัน

ความแตกต่างที่ซับซ้อนและโครงสร้างการสืบทอดอาจสร้างขึ้นจากลักษณะ ตัวอย่างเช่น หลายคุณสมบัติ เช่นเดียวกับคลาสที่นำไปใช้ ทั้งหมดอาจแทนที่เมธอดที่มีลายเซ็นเดียวกัน โดยเรียก super.methodName() เพื่อส่งผ่านการควบคุมไปยังคุณสมบัติถัดไป เมื่อคอมไพเลอร์ Scala พบการเรียกดังกล่าว มันจะ:

  • กำหนดลักษณะที่แน่นอนของการโทรนี้
  • กำหนดชื่อของคลาสประกอบที่จัดเตรียม bytecode วิธีสแตติกที่กำหนดไว้สำหรับลักษณะ
  • สร้างคำสั่ง invokestatic ที่จำเป็น

ดังนั้น เราจะเห็นได้ว่ามีการนำแนวคิดที่มีประสิทธิภาพของคุณลักษณะมาใช้ในระดับ JVM ในลักษณะที่ไม่ทำให้เกิดโอเวอร์เฮดที่มีนัยสำคัญ และโปรแกรมเมอร์ Scala อาจเพลิดเพลินกับคุณลักษณะนี้โดยไม่ต้องกังวลว่าจะมีราคาแพงเกินไปที่รันไทม์

Singletons

สกาล่าให้คำจำกัดความที่ชัดเจนของคลาสซิงเกิลตันโดยใช้ object คีย์เวิร์ด ลองพิจารณาคลาสซิงเกิลตันต่อไปนี้:

 object Config { val home_dir = "/home/user" }

คอมไพเลอร์สร้างไฟล์คลาสสองไฟล์:

ซอร์สโค้ดถูกคอมไพล์เป็นไฟล์คลาสสองไฟล์

Config.class นั้นค่อนข้างเรียบง่าย:

 public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn

นี่เป็นเพียงมัณฑนากรสำหรับคลาส Config$ สังเคราะห์ที่ฝังฟังก์ชันการทำงานของซิงเกิลตัน การตรวจสอบคลาสนั้นด้วย javap -p -c จะสร้าง bytecode ต่อไปนี้:

 public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return

ประกอบด้วยสิ่งต่อไปนี้:

  • ตัวแปรสังเคราะห์ MODULE$ ซึ่งอ็อบเจ็กต์อื่นๆ เข้าถึงอ็อบเจ็กต์ซิงเกิลตันนี้
  • static initializer {} (เรียกอีกอย่างว่า <clinit> ตัวเริ่มต้นคลาส) และวิธีการส่วนตัว Config$ ใช้เพื่อเริ่มต้น MODULE$ และตั้งค่าฟิลด์เป็นค่าเริ่มต้น
  • เมธอด getter สำหรับฟิลด์สแตติก home_dir ในกรณีนี้เป็นเพียงวิธีเดียวเท่านั้น หากซิงเกิลตันมีฟิลด์มากกว่า ก็จะมีผู้ได้รับมากกว่า รวมถึงตัวตั้งค่าสำหรับฟิลด์ที่เปลี่ยนแปลงได้

ซิงเกิลตันเป็นรูปแบบการออกแบบที่ได้รับความนิยมและมีประโยชน์ ภาษา Java ไม่ได้ให้วิธีการโดยตรงในการระบุที่ระดับภาษา ค่อนข้างเป็นความรับผิดชอบของนักพัฒนาในการนำไปใช้ในซอร์ส Java ในทางกลับกัน Scala เป็นวิธีที่สะดวกและชัดเจนในการประกาศซิงเกิลตันอย่างชัดเจนโดยใช้คีย์เวิร์ดของ object ดังที่เราเห็นได้ภายใต้ประทุน มันถูกนำไปใช้ในทางที่ไม่แพงและเป็นธรรมชาติ

บทสรุป

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

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


Java Virtual Machine: A Crash Course

เช่นเดียวกับคอมไพเลอร์ Java คอมไพเลอร์ Scala จะแปลงซอร์สโค้ดเป็นไฟล์ .class ซึ่งมี Java bytecode ที่จะรันโดย Java Virtual Machine เพื่อให้เข้าใจว่าทั้งสองภาษาแตกต่างกันอย่างไรภายใต้ประทุน จำเป็นต้องเข้าใจระบบที่ทั้งสองเป็นเป้าหมาย ที่นี่ เรานำเสนอภาพรวมโดยย่อขององค์ประกอบหลักบางอย่างของสถาปัตยกรรม Java Virtual Machine โครงสร้างไฟล์คลาส และพื้นฐานของแอสเซมเบลอร์

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

การถอดรหัสไฟล์คลาสด้วย javap
สระคงที่
ตารางสนามและเมธอด
JVM Bytecode
วิธีการโทรและ Call Stack
การดำเนินการบน Operand Stack
ตัวแปรท้องถิ่น
กลับไปด้านบน

การถอดรหัสไฟล์คลาสด้วย javap

Java มาพร้อมกับยูทิลิตี้บรรทัดคำสั่ง javap ซึ่งถอดรหัสไฟล์ .class ให้อยู่ในรูปแบบที่มนุษย์อ่านได้ เนื่องจากไฟล์คลาส Scala และ Java ทั้งคู่กำหนดเป้าหมายไปที่ JVM เดียวกัน จึงสามารถใช้ javap เพื่อตรวจสอบไฟล์คลาสที่รวบรวมโดย Scala ได้

มารวบรวมซอร์สโค้ดต่อไปนี้:

 // RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }

การคอมไพล์สิ่งนี้ด้วย scalac RegularPolygon.scala จะสร้าง RegularPolygon.class หากเราเรียกใช้ javap RegularPolygon.class เราจะเห็นสิ่งต่อไปนี้:

 $ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

นี่เป็นการแยกย่อยไฟล์คลาสอย่างง่ายที่แสดงชื่อและประเภทของสมาชิกสาธารณะของชั้นเรียน การเพิ่มตัวเลือก -p จะรวมถึงสมาชิกส่วนตัว:

 $ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

ข้อมูลนี้ยังไม่มาก หากต้องการดูวิธีการนำไปใช้ใน Java bytecode ให้เพิ่มตัวเลือก -c :

 $ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }

ที่น่าสนใจกว่าเล็กน้อย อย่างไรก็ตาม เพื่อให้ได้เรื่องราวทั้งหมดจริงๆ เราควรใช้ตัวเลือก -v หรือ -verbose เช่นเดียวกับใน javap -p -v RegularPolygon.class :

เนื้อหาที่สมบูรณ์ของไฟล์คลาส Java

ในที่สุดเราก็เห็นสิ่งที่อยู่ในไฟล์คลาสจริงๆ ทั้งหมดนี้หมายความว่าอย่างไร มาดูส่วนที่สำคัญที่สุดกันบ้าง

สระคงที่

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

  • ชื่อชั้น
  • ชื่อช่องหรือเมธอด
  • พิมพ์ข้อมูล

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

 // ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...

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

ตัวอย่างเช่น คลาส Java ที่ใช้จำนวนเต็มคงที่ 365 อาจมีรายการพูลคงที่ด้วย bytecode ต่อไปนี้:

 x03 00 00 01 6D

ไบต์แรก x03 ระบุประเภทรายการ CONSTANT_Integer สิ่งนี้แจ้งตัวเชื่อมโยงว่าสี่ไบต์ถัดไปมีค่าของจำนวนเต็ม (โปรดทราบว่า 365 ในเลขฐานสิบหกคือ x16D ) หากนี่เป็นรายการที่ 14 ในพูลคงที่ javap -v จะแสดงผลดังนี้:

 #14 = Integer 365

ค่าคงที่หลายประเภทประกอบด้วยการอ้างอิงถึงประเภทค่าคงที่ "ดั้งเดิม" มากกว่าในที่อื่นๆ ในกลุ่มค่าคงที่ ตัวอย่างเช่น โค้ดตัวอย่างของเราประกอบด้วยคำสั่ง:

 println( "Calculating perimeter..." )

การใช้ค่าคงที่สตริงจะสร้างสองรายการในพูลคงที่: รายการหนึ่งที่มีประเภท CONSTANT_String และรายการอื่นประเภท CONSTANT_Utf8 รายการประเภท Constant_UTF8 มีการแทนค่า UTF8 ที่แท้จริงของค่าสตริง รายการประเภท CONSTANT_String มีการอ้างอิงถึงรายการ CONSTANT_Utf8 :

 #24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...

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

 #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I

สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับพูลค่าคงที่ โปรดดูเอกสารประกอบ JVM

ตารางสนามและเมธอด

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

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

JVM Bytecode

JVM ใช้ชุดคำสั่งภายในของตัวเองเพื่อรันโค้ดที่คอมไพล์แล้ว การรัน javap ด้วยอ็อพชัน -c รวมถึงการนำเมธอดที่คอมไพล์ไปใช้ในเอาต์พุต หากเราตรวจสอบไฟล์ RegularPolygon.class ด้วยวิธีนี้ เราจะเห็นผลลัพธ์ต่อไปนี้สำหรับ getPerimeter() ของเรา:

 public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn

bytecode จริงอาจมีลักษณะดังนี้:

 xB2 00 17 x12 19 xB6 00 1D x27 ...

แต่ละคำสั่งเริ่มต้นด้วย opcode หนึ่งไบต์ที่ระบุคำสั่ง JVM ตามด้วยตัวถูกดำเนินการของคำสั่งศูนย์หรือมากกว่าที่จะใช้งาน ขึ้นอยู่กับรูปแบบของคำสั่งเฉพาะ โดยทั่วไปแล้วสิ่งเหล่านี้เป็นค่าคงที่ หรือการอ้างอิงไปยังกลุ่มค่าคงที่ javap ช่วยแปล bytecode ให้อยู่ในรูปแบบที่มนุษย์สามารถอ่านได้:

  • ออฟเซ็ต หรือตำแหน่งของไบต์แรกของคำสั่งภายในโค้ด
  • ชื่อที่มนุษย์สามารถอ่านได้ หรือ ช่วยในการ จำ ของคำสั่ง
  • ค่าของตัวถูกดำเนินการ ถ้ามี

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

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

วิธีการโทรและ Call Stack

การเรียกเมธอดแต่ละครั้งต้องสามารถรันได้ด้วยบริบทของตัวเอง ซึ่งรวมถึงสิ่งต่างๆ เช่น ตัวแปรที่ประกาศในเครื่อง หรืออาร์กิวเมนต์ที่ส่งผ่านไปยังเมธอด สิ่งเหล่านี้ประกอบกันเป็น สแต็กเฟรม เมื่อเรียกใช้เมธอด เฟรมใหม่จะถูกสร้างขึ้นและวางไว้บน call stack เมื่อเมธอดกลับมา เฟรมปัจจุบันจะถูกลบออกจากสแต็กการโทรและละทิ้ง และเฟรมที่มีผลก่อนเมธอดจะถูกเรียกคืน

สแต็กเฟรมประกอบด้วยโครงสร้างที่แตกต่างกันสองสามอย่าง สองสิ่งที่สำคัญคือ Operand stack และ local variable table ซึ่งจะกล่าวถึงต่อไป

สแต็กการโทร JVM

การดำเนินการบน Operand Stack

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

 sideLength * this.numSides

ถูกคอมไพล์ดังต่อไปนี้ใน getPerimeter() ของเรา:

 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 

คำสั่ง JVM สามารถทำงานบน Operand stack เพื่อทำหน้าที่ที่ซับซ้อนได้

  • คำสั่งแรก dload_1 ผลักการอ้างอิงอ็อบเจ็กต์จากสล็อต 1 ของตารางตัวแปรโลคัล (จะกล่าวถึงต่อไป) ไปยังตัวถูกดำเนินการสแต็ก ในกรณีนี้ นี่คือวิธีอาร์กิวเมนต์ sideLength .- คำสั่งถัดไป aload_0 ผลักการอ้างอิงวัตถุที่ช่อง 0 ของตารางตัวแปรในเครื่องไปยังกองตัวถูกดำเนินการ ในทางปฏิบัติ นี่มักจะเป็นการอ้างอิงถึง this คลาสปัจจุบัน
  • สิ่งนี้จะตั้งค่าสแต็กสำหรับการโทรครั้งต่อไป invokevirtual #31 ซึ่งรันเมธอดของอินสแตนซ์ numSides() invokevirtual ตัวถูกดำเนินการด้านบน (การอ้างอิงถึง this ) ออกจากสแต็กเพื่อระบุคลาสที่ต้องเรียกใช้เมธอด เมื่อเมธอดกลับมา ผลลัพธ์ของเมธอดจะถูกผลักไปที่สแต็ก
  • ในกรณีนี้ ค่าที่ส่งคืน ( numSides ) อยู่ในรูปแบบจำนวนเต็ม ต้องแปลงเป็นรูปแบบจุดทศนิยมสองเท่าเพื่อคูณด้วยค่าสองเท่าอื่น คำแนะนำ i2d ค่าจำนวนเต็มออกจากสแต็ก แปลงเป็นรูปแบบทศนิยม และดันกลับเข้าสู่สแต็ก
  • ณ จุดนี้ สแต็กมีผลทศนิยมของ this.numSides อยู่ด้านบน ตามด้วยค่าของอาร์กิวเมนต์ sideLength ที่ส่งผ่านไปยังเมธอด dmul แสดงค่าสองค่าบนสุดเหล่านี้จากสแต็ก ทำการคูณทศนิยมบนค่าเหล่านั้น และส่งผลลัพธ์ไปยังสแต็ก

เมื่อมีการเรียกเมธอด สแต็กตัวถูกดำเนินการใหม่จะถูกสร้างขึ้นเป็นส่วนหนึ่งของเฟรมสแต็กของมัน ซึ่งการดำเนินการต่างๆ จะถูกดำเนินการ We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.

Local Variables

Each stack frame keeps a table of local variables . This typically includes a reference to this object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap with the -v option will include information about how each method's stack frame should be set up, including its local variable table:

 public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D

In this example, there are two local variables. The variable in slot 0 is named this , with the type RegularPolygon . This is the reference to the method's own class. The variable in slot 1 is named sideLength , with the type D (indicating a double). This is the argument that is passed to our getPerimeter() method.

Instructions such as iload_1 , fstore_2 , or aload [n] , transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this , the instruction aload_0 is commonly seen in any method that operates on its own class.

This concludes our walkthrough of JVM basics.

ที่เกี่ยวข้อง: ลดรหัส Boilerplate ด้วย Scala Macros และ Quasiquotes