Hello World из байт-кода для JVM
Не думаю, что статья будет достаточно информативной для тех, кто поверхностно не знает как выглядит байт-код и как с ним работает JVM (например, хотя бы простейшие инструкции (знание об их существовании)).
На самом деле, это не так сложно. Достаточно использовать инструмент javap из JDK и рассмотреть дизассемблированный код.
А мы приступим к разбору самой структуры байт-кода для JVM
Очень полезной книгой для этого стала официальная спецификация JVM — The Java Virtual Machine Specification на сайте oracle
Для начала создадим простенькую программу:
public class Main < public static void main(String . args) < System.out.println("Hello World"); >>
Скомпилируем её командой javac Main.java и собственно сделаем дизассемблинг
javap -c -v Main
Classfile /C:/Users/Arthur/playground/java/jvm/Main.class Last modified 26.10.2019; size 413 bytes MD5 checksum 6449121a3bb611fee394e4f322401ee1 Compiled from "Main.java" public class Main minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // Hello World #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // Main #6 = Class #22 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 Main.java #15 = NameAndType #7:#8 // "":()V #16 = Class #23 // java/lang/System #17 = NameAndType #24:#25 // out:Ljava/io/PrintStream; #18 = Utf8 Hello World #19 = Class #26 // java/io/PrintStream #20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V #21 = Utf8 Main #22 = Utf8 java/lang/Object #23 = Utf8 java/lang/System #24 = Utf8 out #25 = Utf8 Ljava/io/PrintStream; #26 = Utf8 java/io/PrintStream #27 = Utf8 println #28 = Utf8 (Ljava/lang/String;)V < public Main(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 1: 0 public static void main(java.lang.String. ); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello World 5: invokevirtual #4// Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 4: 0 line 5: 8 > SourceFile: "Main.java"
Это просто представление байт-кода, которое человеку видеть легче, чем оригинальный байт-код, но сам он выглядит иначе:
cafe babe 0000 0034 001d 0a00 0600 0f09 0010 0011 0800 120a 0013 0014 0700 1507 0016 0100 063c 696e 6974 3e01 0003 2829 5601 0004 436f 6465 0100 0f4c 696e 654e 756d 6265 7254 6162 6c65 0100 046d 6169 6e01 0016 285b 4c6a 6176 612f 6c61 6e67 2f53 7472 696e 673b 2956 0100 0a53 6f75 7263 6546 696c 6501 0009 4d61 696e 2e6a 6176 610c 0007 0008 0700 170c 0018 0019 0100 0b48 656c 6c6f 2057 6f72 6c64 0700 1a0c 001b 001c 0100 044d 6169 6e01 0010 6a61 7661 2f6c 616e 672f 4f62 6a65 6374 0100 106a 6176 612f 6c61 6e67 2f53 7973 7465 6d01 0003 6f75 7401 0015 4c6a 6176 612f 696f 2f50 7269 6e74 5374 7265 616d 3b01 0013 6a61 7661 2f69 6f2f 5072 696e 7453 7472 6561 6d01 0007 7072 696e 746c 6e01 0015 284c 6a61 7661 2f6c 616e 672f 5374 7269 6e67 3b29 5600 2100 0500 0600 0000 0000 0200 0100 0700 0800 0100 0900 0000 1d00 0100 0100 0000 052a b700 01b1 0000 0001 000a 0000 0006 0001 0000 0001 0089 000b 000c 0001 0009 0000 0025 0002 0001 0000 0009 b200 0212 03b6 0004 b100 0000 0100 0a00 0000 0a00 0200 0000 0400 0800 0500 0100 0d00 0000 0200 0e
(Можете открыть ваш .class файл через Sublime Text указав при этом File-> Save with Encoding -> Hexademical)
С этим кодом мы и будем работать.
Но для начала нам нужно его отформатировать, чтобы не путаться что где находится, а байт-код, на самом деле, имеет вполне жесткую структуру:
ClassFile
Её вы можете найти в спецификации JVM Chapter 4.1 The ClassFile Structure
Тут все просто — слева указана размерность в байтах, а справа описание.
Разбирать байт-код мы будем в hexadecimal, где каждая цифра занимает 4 бита, а следовательно, на два байта — 4 цифры и на четыре байта — 8 цифр.
magic
magic — это значение, которое идентифицирует формат нашего класса. Он равен 0xCAFEBABE , который имеет свою историю создания.
minor_version, major_version
Это версии вашего class файла. Если мы назовем major_version M и minor_version m, то получаем версию нашего class файла как M.m
Сейчас я сразу буду приводить примеры из нашей программы «Hello World», чтобы посмотреть как они используются:
cafe babe -- magic 0000 -- minor_version 0034 -- major_version
Его же мы можем видеть в дизассемблированном коде, но уже в десятичной системе счисления:
. public class Main minor version: 0 major version: 52 flags: ACC_PUBLIC, .
constant_pool_count
Здесь указывается количество переменных в пуле констант. При этом, если вы решили писать код на чистом байт-коде, то вам обязательно нужно следить за его значением, так как если вы укажете не то значение, то вся программа полетит к чертям (проверено!).
Также следует не забывать, что вы должны писать туда количество_переменных_в_пуле + 1
cafe babe -- magic 0000 0034 -- version 001d -- constant_pool_count
constant_pool[]
Каждый тип переменной в пуле констант имеет свою структуру:
cp_info
Здесь все нужно делать последовательно. Сначала считываем tag , чтобы узнать тип переменной и по типу этой переменной смотрим какую структуру имеет последующее его значение info[]
Таблица с тэгами можно найти в спецификации Table 4.3 Constant pool tags
Собственно, вот табличка:
Constant Type | Value |
---|---|
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_String | 8 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_InvokeDynamic | 18 |
Как ранее уже говорилось, каждый тип константы имеет свою структуру.
Вот, например, структура CONSTANT_Class :
CONSTANT_Class_info
Структура поля и метода:
CONSTANT_Fieldref_info < u1 tag; u2 class_index; u2 name_and_type_index; >CONSTANT_Methodref_info
Тут важно заметить, что разные структуры, могут иметь разную длину.
Рассмотрим часть нашего кода:
cafe babe 0000 0034 001d -- constant_pool_count 0a00 0600 0f09 0010 0011 0800 12 .
Итак, смотрим на структуру константы и узнаем, что первый байт отведен под тип константы. Здесь мы видим 0a (10) — а, следовательно, это CONSTANT_Methodref
Смотрим его структуру:
CONSTANT_Methodref_info
После одного байта для тэга, нам нужно еще 4 байта для class_index и name_and_type_index
cafe babe 0000 0034 001d -- constant_pool_count 0a 0006 000f -- CONSTANT_Methodref 0900 1000 1108 0012 .
Отлично, мы нашли одну из значений пула констант. Идем дальше. Смотрим, 09 — значит тип CONSTANT_Fieldref
cafe babe 0000 0034 001d -- constant_pool_count 0a 0006 000f -- CONSTANT_Methodref 09 0010 0011 -- CONSTANT_Fieldref 08 0012 .
Вам может показаться, что большинство типов имеет одинаковую форму, но это не так.
Например, структура следующего типа выглядит так, CONSTANT_String :
CONSTANT_String_info
Все эти структуры можно посмотреть в Chapter 4.4 The Constant Pool
Теперь разберем, что значат типы внутри самого info
Методы, которые попадают под паттерн *_index обычно содержат адрес из таблицы пула констант. Например, class_index на значение с типом CONSTANT_Class_info , а string_index на CONSTANT_Utf8_info
Это же мы можем видеть в дизассемблированном коде:
#1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18
0a 0006 000f -- CONSTANT_Methodref 09 0010 0011 -- CONSTANT_Fieldref 08 0012 -- CONSTANT_String
Также можно выделить представление чисел и строк.
Про представление чисел можно прочитать начиная с главы 4.4.4, а мы пока разберем лишь строки, так как числа пока не входят в программу Hello World
Собственно, вот так представляется строка:
CONSTANT_Utf8_info
Например, наш Hello World:
01 -- tag 000b -- length 48 65 6c 6c 6f 20 57 6f 72 6c 64 -- bytes[length] // H e l l o W o r l d
Разбирая весь пул констант байт-кода, получим:
Весь пул констант
-- [Constant Pool] -- methodref 0a 0006 000f -- fieldref 09 0010 0011 -- string 08 0012 -- methodref 0a 0013 0014 -- Class 07 0015 -- Class 07 0016 -- Utf8 01 0006 3c 69 6e 69 74 3e -- Utf8 01 0003 28 29 56 -- Utf8 01 0004 43 6f 64 65 -- Utf8 01 000f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 -- Utf8 01 0004 6d 61 69 6e -- Utf8 01 0016 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 -- Utf8 01 000a 53 6f 75 72 63 65 46 69 6c 65 -- Utf8 01 0009 4d 61 69 6e 2e 6a 61 76 61 -- NameAndType 0c 0007 0008 -- Class 07 0017 -- NameAndType 0c 0018 0019 -- Utf8 01 000b 48 65 6c 6c 6f 20 57 6f 72 6c 64 -- Class 07 001a -- NameAndType 0c 001b 001c -- Utf8 01 0004 4d 61 69 6e -- Utf8 01 0010 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 -- Utf8 01 0010 6a 61 76 61 2f 6c 61 6e 67 2f 53 79 73 74 65 6d -- Utf8 01 0003 6f 75 74 -- Utf8 01 0015 4c 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 3b -- Utf8 01 0013 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d -- Utf8 01 0007 70 72 69 6e 74 6c 6e -- Utf8 01 0015 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 -- [Constant Pool END]
Также, мы можем сравнить его с дизассемблированным кодом:
Constant pool: #1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // Hello World #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // Main #6 = Class #22 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 Main.java #15 = NameAndType #7:#8 // "":()V #16 = Class #23 // java/lang/System #17 = NameAndType #24:#25 // out:Ljava/io/PrintStream; #18 = Utf8 Hello World #19 = Class #26 // java/io/PrintStream #20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V #21 = Utf8 Main #22 = Utf8 java/lang/Object #23 = Utf8 java/lang/System #24 = Utf8 out #25 = Utf8 Ljava/io/PrintStream; #26 = Utf8 java/io/PrintStream #27 = Utf8 println #28 = Utf8 (Ljava/lang/String;)V
Тем самым проверив, что все совпадает, ведь по сути javap просто обрабатывает этот байт-код и показывает нам его в форматированном виде.
Пул констант нужен для инструкций. Например:
public Main(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // ссылается на адрес 1 в пуле констант 4: return
Подробнее обо всех типах в пуле констант можно узнать в Chapter 4.4 The Constant Pool
Идем дальше по структуре ClassFile
access_flags
Это битовая маска для свойств модификаторов
Flag Name | Value | Interpretation |
---|---|---|
ACC_PUBLIC | 0x0001 | Declared public ; may be accessed from outside its package. |
ACC_FINAL | 0x0010 | Declared final ; no subclasses allowed. |
ACC_SUPER | 0x0020 | Treat superclass methods specially when invoked by the invokespecial instruction. |
ACC_INTERFACE | 0x0200 | Is an interface, not a class. |
ACC_ABSTRACT | 0x0400 | Declared abstract ; must not be instantiated. |
ACC_SYNTHETIC | 0x1000 | Declared synthetic; not present in the source code. |
ACC_ANNOTATION | 0x2000 | Declared as an annotation type. |
ACC_ENUM | 0x4000 | Declared as an enum type. |
this_class
Должна содержать адрес на this класса. В нашем случае, она находится по адресу 5:
Constant pool: #1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // Hello World #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // Main #6 = Class #22 // java/lang/Object .
Следует заметить, что структуру этой переменной должна соответствовать CONSTANT_Class_info
super_class
Адрес предка класса. В нашем случае, значение по адресу #6 . Ну, и также обязательным является структура значения CONSTANT_Class_info
Имена этих классов заданы в структуре константы CONSTANT_Utf8_info . Если мы посмотрим ячейки #21 и #22 , то увидим:
. #21 = Utf8 Main #22 = Utf8 java/lang/Object .
То есть в этих ячейках указан name_index из структуры:
CONSTANT_Class_info
interfaces_count, fields_count
Их в нашей программе нет, поэтому их значения будут равны 0000, а последующих значений fields[] , interfaces[] просто не будет.
methods_count
Количество методов. Хоть и в коде мы видим один метод в классе, но, на самом деле, их два. Кроме main метода еще есть конструктор по умолчанию. Поэтому их количество равно двум, в нашем случае.
methods[]
Каждый элемент должен соответствовать структуре method_info описанной в Chapter 4.6 Methods
method_info
В нашем байт-коде (отформатированном, с комментариями) выглядит это так:
-- [methods] -- public Main(); 0001 --access_flags 0007 -- name_index 0008 -- descriptor_index 0001 -- attributes_count -- attribute_info 0009 -- attribute_name_index (Code) 0000 001d - attribute_length 0001 -- max_stack 0001 -- max_locals 0000 0005 -- code_length 2a b7 00 01 b1 -- code[] 0000 -- exception_table_length 0001 -- attributes_count; 000a -- attribute_name_index 0000 0006 -- attribute_length 00 01 00 00 00 01 -- public static void main(java.lang.String. ); 0089 --access_flags 000b -- name_index 000c -- descriptor_index 0001 -- attributes_count -- attribute_info 0009 -- attribute_name_index (Code) 0000 0025 -- attribute_length 0002 -- max_stack 0001 -- max_locals 0000 0009 -- code_length b2 00 02 12 03 b6 00 04 b1 -- code[] 0000 -- exception_table_length 0001 -- attributes_count 000a -- attribute_name_index 0000 000a -- attribute_length 00 02 00 00 00 04 00 08 00 05 -- [methods END]
Разберем по-подробнее структуру методов:
access_flags
Flag Name | Value | Interpretation |
---|---|---|
ACC_PUBLIC | 0x0001 | Declared public ; may be accessed from outside its package. |
ACC_PRIVATE | 0x0002 | Declared private ; accessible only within the defining class. |
ACC_PROTECTED | 0x0004 | Declared protected ; may be accessed within subclasses. |
ACC_STATIC | 0x0008 | Declared static . |
ACC_FINAL | 0x0010 | Declared final ; must not be overridden (§5.4.5). |
ACC_SYNCHRONIZED | 0x0020 | Declared synchronized ; invocation is wrapped by a monitor use. |
ACC_BRIDGE | 0x0040 | A bridge method, generated by the compiler. |
ACC_VARARGS | 0x0080 | Declared with variable number of arguments. |
ACC_NATIVE | 0x0100 | Declared native ; implemented in a language other than Java. |
ACC_ABSTRACT | 0x0400 | Declared abstract ; no implementation is provided. |
ACC_STRICT | 0x0800 | Declared strictfp ; floating-point mode is FP-strict. |
ACC_SYNTHETIC | 0x1000 | Declared synthetic; not present in the source code. |
Как мы можем видеть из байт-кода, в методе public Main(); (конструктор) стоит маска 0001 , который означает ACC_PUBLIC .
А теперь сами попробуем собрать метод main . Вот, что у него есть:
- public — ACC_PUBLIC — 0x0001
- static — ACC_STATIC — 0x0008
- String… args — ACC_VARARGS — 0x0080
Собираем маску: 0x0001 + 0x0008 + 0x0080 = 0x0089 . Итак, мы получили access_flag
К слову, ACC_VARARGS здесь необязательный, в том плане, что, если бы мы
использовали String[] args вместо String… args, то этого флага бы не было
name_index
Адрес имени метода ( CONSTANT_Utf8_info ) в пуле констант. Здесь важно заметить, что имя конструктора это не Main, а , расположенная в ячейке #7.
descriptor_index
Грубо говоря, это адрес указывающий на дескриптор метода. Этот дескриптор содержит тип возвращаемого значения и тип его сигнатуры.
Также, в JVM используются интерпретируемые сокращения:
BaseType Character | Type | Interpretation |
---|---|---|
B | byte | signed byte |
C | char | Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16 |
D | double | double-precision floating-point value |
F | float | single-precision floating-point value |
I | int | integer |
J | long | long integer |
L ClassName ; | reference | an instance of class ClassName |
S | short | signed short |
Z | boolean | true or false |
[ | reference | one array dimension |
В общем случае это выглядит так:
( ParameterDescriptor* ) ReturnDescriptor
Например, следующий метод:
Object method(int i, double d, Thread t)
Можно представить в виде
(IDLjava/lang/Thread;)Ljava/lang/Object
Собственно, I — это int , D — это double , а Ljava/lang/Thread; класс Thread из стандартной библиотеки java.lang .
Далее, идут атрибуты, которые также имеют свою структуру.
Но сначала, как и всегда, идет его количество attributes_count
Затем сами атрибуты со структурой описанной в Chapter 4.7 Attributes
attribute_info
attribute_name_index
Указание имени атрибута. В нашем случае, у обоих методов это Code . Атрибуты это отдельная большая тема, в котором можно по спецификации создавать даже свои атрибуты. Но нам пока следует знать, что attribute_name_index просто указывает на адрес в пуле констант со структурой CONSTANT_Utf8_info
attribute_length
Содержит длину атрибута, не включая attribute_name_index и attribute_length
info
Далее, мы будем использовать структуру Code , так как в значении attribute_name_index мы указали на значение в пуле констант Code .
Вот его структура:
Code_attribute < u2 attribute_name_index; u4 attribute_length; u2 max_stack; u2 max_locals; u4 code_length; u1 code[code_length]; u2 exception_table_length; < u2 start_pc; u2 end_pc; u2 handler_pc; u2 catch_type; >exception_table[exception_table_length]; u2 attributes_count; attribute_info attributes[attributes_count]; >
max_stack
Мне кажется, что имя этого атрибута может ввести в заблуждение из-за приставки max. На самом деле, это минимальный размер стека нужный для выполнения операции. Ну, это имя приобретает логику, если сказать, максимальный размер стека, который будет достигнут во время выполнения операции.
Упрощенно говоря, JVM выделит место для стека операндов. Там можно указывать значение, которое больше, чем нужно, но определение в этом атрибуте значения меньше, чем нужно приведет к ошибке.
max_locals
Максимальный размер локальных переменных
Ознакомится с локальными переменными можно либо в Mastering Java Bytecode at the Core of the JVM или в том же JVM Internals
code_length
Размер кода, который будет исполнятся внутри метода
code[]
Каждый код указывает на какую-то инструкцию. Таблицу соотношения optcode и команды с мнемоникой можно найти в википедии — Java bytecode instruction listings или в самой спецификации в конце книги
Для примера, возьмем наш конструктор:
-- public Main(); 0001 --access_flags 0007 -- name_index 0008 -- descriptor_index 0001 -- attributes_count -- attribute_info 0009 -- attribute_name_index (Code) 0000 001d - attribute_length 00 01 -- max_stack 00 01 -- max_locals 00 00 00 05 -- code_length 2a b7 00 01 b1 -- code[] 0000 -- exception_table_length 0001 -- attributes_count; 00 0a -- attribute_name_index 0000 0006 -- attribute_length 00 01 00 00 00 01
Здесь мы можем найти наш код:
2a b7 00 01 b1
Ищем в таблице команды и сопоставляем:
2a - aload_0 b7 0001 - invokespecial #1 b1 - return
Также описания этих команд можно найти здесь: Chapter 4.10.1.9. Type Checking Instructions
exception_table_length
Задает число элементов в таблице exception_table. У нас пока нет перехватов исключений поэтому разбирать его не будем. Но дополнительно можно почитать Chapter 4.7.3 The Code Attribute
exception_table[]
Имеет вот такую структуру:
Если упрощать, то нужно указать начало, конец ( start_pc , end_pc ) кода, который будет обрабатывать handler_pc и тип исключения catch_type
attributes_count
Количество атрибутов в Code
attributes[]
Атрибуты, часто используются анализаторами или отладчиками.
Средства для работы с байт-кодом
Это немного не та тема, которая относится к данной статье, но все же косвенно связанная с ней.
Средств для работы с байт-кодом, на самом деле, достаточно много. Здесь я бы хотел рассмотреть Byte Code Engineering Library (BCEL) от Apache Commons.
Для начала, с помощью него мы может получить некоторые атрибуты байт-кода:
// read file from resources/complied/ClassA.class InputStream inputStream = ClassParserExample.class.getResourceAsStream("/compiled/ClassA.class"); if (inputStream == null) throw new FileNotFoundException(); ClassParser parser = new ClassParser(inputStream, "ClassA.class"); JavaClass clazz = parser.parse(); final String HEX_BYTECODE = getHex(clazz.getBytes()); System.out.println("Hex bytecode: "); System.out.println(HEX_BYTECODE); System.out.println(); final String MINOR_VER = getHex(clazz.getMinor()); final String MAJOR_VER = getHex(clazz.getMajor()); final String CONSTANT_POOL = getHex(clazz.getConstantPool().getConstantPool()); final String ACCESS_FLAGS = getHex(clazz.getAccessFlags()); final String THIS_CLASS = getHex(clazz.getClassName().getBytes()); final String SUPER_CLASS = getHex(clazz.getSuperClass().getBytes()); final String INTERFACES = getHex(clazz.getInterfaces()); final String FIELDS = getHex(clazz.getFields()); final String METHODS = getHex(clazz.getMethods()); final String ATTRIBUTES = getHex(clazz.getAttributes());
Полный листинг кода
import org.apache.bcel.classfile.*; import org.apache.commons.codec.binary.Hex; import java.io.*; public class ClassParserExample < public static void main(String. args) throws IOException, ClassNotFoundException < // read file from resources/complied/ClassA.class InputStream inputStream = ClassParserExample.class.getResourceAsStream("/compiled/ClassA.class"); if (inputStream == null) throw new FileNotFoundException(); ClassParser parser = new ClassParser(inputStream, "ClassA.class"); JavaClass clazz = parser.parse(); final String HEX_BYTECODE = getHex(clazz.getBytes()); System.out.println("Hex bytecode: "); System.out.println(HEX_BYTECODE); System.out.println(); final String MINOR_VER = getHex(clazz.getMinor()); final String MAJOR_VER = getHex(clazz.getMajor()); final String CONSTANT_POOL = getHex(clazz.getConstantPool().getConstantPool()); final String ACCESS_FLAGS = getHex(clazz.getAccessFlags()); final String THIS_CLASS = getHex(clazz.getClassName().getBytes()); final String SUPER_CLASS = getHex(clazz.getSuperClass().getBytes()); final String INTERFACES = getHex(clazz.getInterfaces()); final String FIELDS = getHex(clazz.getFields()); final String METHODS = getHex(clazz.getMethods()); final String ATTRIBUTES = getHex(clazz.getAttributes()); System.out.println( "minor: " + MINOR_VER ); // 0 System.out.println( "major: " + MAJOR_VER ); // 34 System.out.println( "constant pool: " + CONSTANT_POOL); // not correctly System.out.println( "access flags: " + ACCESS_FLAGS ); // 21 System.out.println( "this class: " + THIS_CLASS ); System.out.println( "super class: " + SUPER_CLASS ); // Object System.out.println( "interfaces: " + INTERFACES ); // System.out.println( "fields: " + FIELDS ); // System.out.println( "methods: " + METHODS ); // one method: psvm hello world System.out.println( "attributes: " + ATTRIBUTES ); // 536f7572636546696c65 - I think it's instructions for Java tools > private static String getHex(byte[] bytes) < return Hex.encodeHexString(bytes); >private static String getHex(int intNum) < return Integer.toHexString(intNum); >private static String getHex(Constant[] constants) < if (constants == null) return null; StringBuilder sb = new StringBuilder(); for (Constant c : constants)< if (c == null) continue; sb.append(getHex(c.getTag())).append(" "); >return sb.toString(); > private static String getHex(JavaClass[] clazzes) < if (clazzes == null) return null; StringBuilder sb = new StringBuilder(); for (JavaClass c : clazzes)< sb.append(getHex(c.getClassName().getBytes())).append(" "); >return sb.toString(); > private static String getHex(Field[] fields) < if (fields == null) return null; StringBuilder sb = new StringBuilder(); for (Field c : fields)< sb.append(getHex(c.getName().getBytes())).append(" "); >return sb.toString(); > private static String getHex(Method[] methods) < if (methods == null) return null; StringBuilder sb = new StringBuilder(); for (Method c : methods)< sb.append(getHex(c.getName().getBytes())).append(" "); >return sb.toString(); > private static String getHex(Attribute[] attributes) < if (attributes == null) return null; StringBuilder sb = new StringBuilder(); for (Attribute c : attributes)< sb.append(getHex(c.getName().getBytes())).append(" "); >return sb.toString(); > > /* Class A: public class ClassA < public static void main(String[] args) < System.out.println("Hello world"); >> */ /* Class A bytecode: cafe babe 0000 0034 0022 0a00 0600 1409 0015 0016 0800 170a 0018 0019 0700 1a07 001b 0100 063c 696e 6974 3e01 0003 2829 5601 0004 436f 6465 0100 0f4c 696e 654e 756d 6265 7254 6162 6c65 0100 124c 6f63 616c 5661 7269 6162 6c65 5461 626c 6501 0004 7468 6973 0100 1d4c 636f 6d2f 6170 706c 6f69 6478 7878 2f70 6172 7365 2f43 6c61 7373 413b 0100 046d 6169 6e01 0016 285b 4c6a 6176 612f 6c61 6e67 2f53 7472 696e 673b 2956 0100 0461 7267 7301 0013 5b4c 6a61 7661 2f6c 616e 672f 5374 7269 6e67 3b01 000a 536f 7572 6365 4669 6c65 0100 0b43 6c61 7373 412e 6a61 7661 0c00 0700 0807 001c 0c00 1d00 1e01 000b 4865 6c6c 6f20 776f 726c 6407 001f 0c00 2000 2101 001b 636f 6d2f 6170 706c 6f69 6478 7878 2f70 6172 7365 2f43 6c61 7373 4101 0010 6a61 7661 2f6c 616e 672f 4f62 6a65 6374 0100 106a 6176 612f 6c61 6e67 2f53 7973 7465 6d01 0003 6f75 7401 0015 4c6a 6176 612f 696f 2f50 7269 6e74 5374 7265 616d 3b01 0013 6a61 7661 2f69 6f2f 5072 696e 7453 7472 6561 6d01 0007 7072 696e 746c 6e01 0015 284c 6a61 7661 2f6c 616e 672f 5374 7269 6e67 3b29 5600 2100 0500 0600 0000 0000 0200 0100 0700 0800 0100 0900 0000 2f00 0100 0100 0000 052a b700 01b1 0000 0002 000a 0000 0006 0001 0000 0006 000b 0000 000c 0001 0000 0005 000c 000d 0000 0009 000e 000f 0001 0009 0000 0037 0002 0001 0000 0009 b200 0212 03b6 0004 b100 0000 0200 0a00 0000 0a00 0200 0000 0800 0800 0900 0b00 0000 0c00 0100 0000 0900 1000 1100 0000 0100 1200 0000 0200 13 */ /* Assembled code: Classfile /C:/java/BCEL/src/main/resources/compiled/ClassA.class Last modified 08.12.2019; size 563 bytes MD5 checksum bcd0198f6764a1dc2f3967fef701452e Compiled from "ClassA.java" public class com.apploidxxx.parse.ClassA minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#20 // java/lang/Object."":()V #2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #23 // Hello world #4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #26 // com/apploidxxx/parse/ClassA #6 = Class #27 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/apploidxxx/parse/ClassA; #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 args #17 = Utf8 [Ljava/lang/String; #18 = Utf8 SourceFile #19 = Utf8 ClassA.java #20 = NameAndType #7:#8 // "":()V #21 = Class #28 // java/lang/System #22 = NameAndType #29:#30 // out:Ljava/io/PrintStream; #23 = Utf8 Hello world #24 = Class #31 // java/io/PrintStream #25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V #26 = Utf8 com/apploidxxx/parse/ClassA #27 = Utf8 java/lang/Object #28 = Utf8 java/lang/System #29 = Utf8 out #30 = Utf8 Ljava/io/PrintStream; #31 = Utf8 java/io/PrintStream #32 = Utf8 println #33 = Utf8 (Ljava/lang/String;)V < public com.apploidxxx.parse.ClassA(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 6: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/apploidxxx/parse/ClassA; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello world 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 8: 0 line 9: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; > SourceFile: "ClassA.java */
Помимо этого мы можем сгенерировать, изменить или дизассемблировать (например, в Jasmin) байт-код.
Парочку примеров можно найти в моем репозитории или в официальных примерах
Также, я уделил внимание и Jasmin. На самом деле, я не знаю, чем оно может быть полезно, но я её использовал при изучении механизма работы JVM с байт-кодом.
С помощью неё можно писать на упрощенном ассемблерном коде:
Hello World
.bytecode 52.0 .source Main.j .class public Main .super java/lang/Object .method public static main([Ljava/lang/String;)V .limit stack 2 .limit locals 2 getstatic java/lang/System/out Ljava/io/PrintStream; ldc "Hello world!" invokevirtual java/io/PrintStream.println(Ljava/lang/String;)V return .end method
Создание объекта
; ClassCreating.j .bytecode 52.0 .source ClassCreating.java .class public ClassCreating .super java/lang/Object .method public ()V .limit stack 1 .limit locals 1 .line 1 0: aload_0 1: invokespecial java/lang/Object/()V 4: return .end method .method public static main([Ljava/lang/String;)V ; Flag ACC_VARARGS set, see JVM spec .limit stack 2 .limit locals 3 .line 3 0: new java/lang/String 3: dup 4: invokespecial java/lang/String/()V 7: astore_1 .line 4 8: new ClassCreating 11: dup 12: invokespecial ClassCreating/()V 15: astore_2 .line 5 16: aload_2 17: invokevirtual ClassCreating/sayHello()V .line 6 20: return .end method .method public sayHello()V .limit stack 2 .limit locals 1 .line 9 0: getstatic java/lang/System/out Ljava/io/PrintStream; 3: ldc "Hello, User!" 5: invokevirtual java/io/PrintStream/println(Ljava/lang/String;)V .line 10 8: return .end method
Вот мы и разобрали простую программку Hello World
Листинг байт-кода с комментариями можно найти на моем гисте: gist.github
Если есть ошибки прошу писать в комментариях или в сообщениях.
Использованная литература
- The Java Virtual Machine Specification — docs.oracle
Введение в байт-код Java
Каждому Java-разработчику известно, какую роль в экосистеме языка играет JVM. Однако большинство не разбирается в том, как работает JVM под капотом. Хотя для разработки на Java это не обязательно, код станет лучше, если вы глубже поймете JVM, потому что так вы будете знать, как каждая строка кода влияет на процессы внутри JVM.
Однако для начала нужно понять, что такое байт-код. Итак, поговорим о вводе и выводе байт-кода Java и о том, как он влияет на JVM во время запуска программы.
Что такое байт-код Java?
Если в какой-то момент профессиональной жизни вы слышали, как проповедуют независимость Java-программ от платформ, скажите спасибо байт-коду.
Байт-код — это набор команд, который JVM применяет для запуска программы. Поскольку байт-код, сгенерированный для программы, не зависит от платформы, где она запущена, вы можете без проблем запускать свою программу на любой машине, на которой есть JVM для интерпретации байт-кода.
Как генерируется байт-код?
Байт-код — это просто результат компиляции класса Java. Файл .class на самом деле представляет собой набор инструкций байт-кода, в которые преобразуется код. Он нуждается в интерпретаторе, таком как JVM, чтобы понимать и выполнять инструкции.
Как посмотреть байт-код Java?
Если вы пытались открыть файл .class , то по опыту должны знать, что это невозможно без декомпилятора. Однако с декомпилятором вы на самом деле видите не байт-код, а Java-код, в который декомпилятор ретранслирует байт-код.
Если вам хочется увидеть сам байт-код, простейший способ — воспользоваться командной строкой.
Следующая команда позволит увидеть фактический байт-код файла .class .
javap -c -p -v [path to the .class file]
Какие здесь флаги?
- -c нужен для дизассемблирования класса Java.
- -p нужен для раскрытия закрытых членов класса.
- -v нужен для просмотра подробной информации, такой как размер стека и пул констант.
Как работает JVM
Прежде чем углубляться в байт-код, стоит понять, как JVM его обрабатывает.
Методы — одна из важнейших составляющих кода для JVM. Среда выполнения Java-программы — это, по сути, набор методов, вызываемых JVM. JVM создает фрейм для каждого такого метода и помещает созданный фрейм наверх стека текущего потока для выполнения.
Фрейм состоит из локальной среды, которая необходима для поддержания его выполнения. Как правило он содержит массив локальных переменных и стек операндов. Посмотрим, что эти элементы из себя представляют.
Массив локальных переменных
Массив локальных переменных, как следует из названия, нужен для хранения локальных переменных в методе. Также он хранит аргументы, которые принимает метод.
В массиве локальных переменных с индексацией от нуля первые индексы используются для хранения аргументов метода. После того, как они будут сохранены, в массив сохранятся другие локальные переменные. Если метод — не статический, а создаёт экземпляры, нулевой индекс будет зарезервирован для хранения ссылки this , которая указывает на экземпляр объекта для вызова метода.
Определим два метода: один статический и один метод экземпляра, но схожие во всем остальном.
public String sayHello(int num, String name) String hello = "Hello, " + name;
return hello;
>
public static String sayHello(int num, String name) String hello = "Hello, " + name;
return hello;
>
Локальные массивы переменных для этих методов будут выглядеть следующим образом:
Стек операндов
Стек операндов — это рабочее пространство внутри фрейма метода. Поскольку это стек, вы можете помещать и забирать значения только из верхней его части. Большинство инструкций байт-кода, принадлежащих определенному методу, либо участвуют в помещении значений в стек, либо забирают значения из стека для обработки.
Инструкция байт-кода load и ее расширения нужны для перемещения значения, хранящегося в массиве переменных, в стек. Инструкция store применяется для извлечения значений из стека и сохранения в массиве переменных. Существуют и другие инструкции, которые извлекают значения из стека для обработки.
Пример такого сценария — команда add , которая извлекает два самых верхних значения из стека и складывает их вместе, а также инструкции вызова метода, которые извлекают самые верхние значения (число зависит от количества параметров, принятых методом) из стека, чтобы передать их в качестве аргументов методу. Если после выполнения команд будут получены результирующие значения, они будут помещены обратно в стек.
aload_0 //отправляет ссылку на непримитивное значение данных в индексе 0 массива переменныхiload_2 //отправляет значение int в индекс 4 массива переменныхiconst_3 //отправляет int 3 в стекiadd //добавляет два самых верхних значения int в стекistore_3 //выводит результат операции добавления и сохраняет в индексе 6 массива переменных
Посмотрим в байт-код
Ради возможности вглядеться в байт-код, я написал простой Java-класс:
package demo;
import java.util.ArrayList;
import java.util.List;
public class SimpleClass
private List evenNums;
public SimpleClass() evenNums = new ArrayList<>();
>
private boolean isEven(int num) return num % 2 == 0;
>
public void addEven(int num) if (isEven(num)) evenNums.add(num);
>
>
>
Скомпилируем класс с помощью команды javac и посмотрим байт-код с помощью javap . Результат выглядит так:
Compiled from "SimpleClass.java"
public class demo.SimpleClass private java.util.List evenNums;
public demo.SimpleClass();
Code:
0: aload_0
1: invokespecial #1 // метод java/lang/Object."":()V
4: aload_0
5: new #2 // класс java/util/ArrayList
8: dup
9: invokespecial #3 // метод java/util/ArrayList."":()V
12: putfield #4 // поле evenNums:Ljava/util/List;
15: return
private boolean isEven(int);
Code:
0: iload_1
1: iconst_2
2: irem
3: ifne 10
6: iconst_1
7: goto 11
10: iconst_0
11: ireturn
public void addEven(int);
Code:
0: aload_0
1: iload_1
2: invokespecial #5 // Метод isEven:(I)Z
5: ifeq 22
8: aload_0
9: getfield #4 // Поле evenNums:Ljava/util/List;
12: iload_1
13: invokestatic #6 // Метод java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
16: invokeinterface #7, 2 // Метод интерфейса java/util/List.add:(Ljava/lang/Object;)Z
21: pop
22: return
>
Посмотрев на инструкции байт-кода, вы обнаружите несколько знакомых команд, включая load и const . Остальное, однако, может даже сбить с толку.
Деконструкция байт-кода
Все не так страшно, как кажется. Попробуем деконструировать байт-код SimpleClasсs шаг за шагом. Начнем с самого простого метода — isEven .
private boolean isEven(int num) return num % 2 == 0;
>
Вот его байт-код:
private boolean isEven(int);
Code:
0: iload_1
1: iconst_2
2: irem
3: ifne 10
6: iconst_1
7: goto 11
10: iconst_0
11: ireturn
- Во-первых, инструкция iload_1 помещает значение массива локальных переменных с индексом 1 в стек операндов. Поскольку метод isEven является методом экземпляра, ссылка на него хранится в нулевом индексе. Тогда легко понять, что значение, хранящееся в индексе 1, на самом деле будет принятым значением параметра int .
- iconst_2 помещает значение 2 в верхнюю часть стека операндов.
- Инструкция irem применяется для нахождения остатка от деления между двумя числами. Это инструкция, которая представляет логику оператора % . Она извлекает два самых верхних значения в стеке и помещает результат обратно в стек.
- Команда ifne сообщает JVM перейти к инструкции с заданным смещением (в данном случае — 10), если значение, обрабатываемое командой, не равно 0. Для реализации этой логики команда берет верхний элемент стека. Если переданное число было четным, то верхний элемент будет равен 0, и в этом случае JVM получает команду перейти к инструкции с индексом 6. Однако, если значение стека не равно нулю, что происходит, когда число нечетное, JVM переходит к инструкции с индексом 10.
- iconst_1 помещает значение int 1 в стек. Это происходит только в том случае, если результат irem равен 1. Здесь 1 представляет логическое значение true .
- goto говорит JVM перейти к инструкции, приведенной в смещении, что в данном случае равно 11. Инструкция goto применяется для перехода с одного места в таблице инструкций на другое.
- iconst_0 помещает в стек значение 0. Эта инструкция идет в дело, когда условие if оказывается ложным. Переданное значение 0 действует как логическое значение false . Инструкции 3, 6, 7 обрабатывают случай, когда условие if истинно.
- ireturn возвращает значение int в верхней части стека.
Здесь важно отметить еще одно: индексы, заданные инструкциям байт-кода — как видим, они не увеличиваются на единицу для каждой новой инструкции.
Число перед инструкцией указывает на индекс ее начального байта. А любой байт-код состоит из однобайтовых опкодов, за которыми следует ноль или более операндов.
Опкоды — это такие команды, как iload , iconst и т.д. В зависимости от размера операндов размер байт-кода может варьироваться от одного байта до нескольких. Отсюда и пробелы в индексах таблицы инструкций. Единственная здесь двухбайтовая инструкция — ifne .
В байт-коде SimpleClass.class есть другие инструкции, такие как invokespecial , invokeinterface и invokestatic , которые в свою очередь являются инструкциями вызова метода.
Вывод
Надеюсь, вам удалось узнать кое-что новое о том, как работает байт-код Java. С этим более четким знанием вы сможете лучше писать код. Можете даже поэкспериментировать с самим байт-кодом во время выполнения программы, воспользовавшись такими библиотеками, как ASM.
- Сборка мусора в Java: что это такое и как работает в JVM
- 9 советов, как выделиться среди Java-разработчиков
- Состояния потоков в Java
Основы Java Bytecode
Как и многие базовые вещи, на habr уже были статьи о bytecode (раз, два), основные же отличия данной статьи — в попытке визуализировать, что происходит внутри, и краткий справочник инструкций (может кому пригодится), многие с примерами использования.
В данной статье будут рассмотрены только основы Java Bytecode. Если вы уже знакомы с его основами, статья вряд ли будет вам интересна, так как практически все можно найти в документации.
В данной статье не рассмотрены многие темы (например, фреймы, многие атрибуты), иначе она бы получилась еще больше
Зачем знать что-то о Bytecode
Тема bytecode довольно скучная и в реальной работе среднестатистического программиста практически не используется.
Так почему стоит знать про основы bytecode:
- Потому что с этим работает Java Machine и хочется понимать, что лежит в основе
- Потому что многие современные фреймворки что-то тихо делают на уровне bytecode и часто могут что-то там сломать (привет, Lombok)
- Потому что просто стало скучно 🙂
Как читать .class файл
Для начала вспомним, как создать .class файл. Для этого воспользуемся
Создается .class файл. Формат class файла — бинарный, файл содержит все, что нужно для выполнения программы JVM. При этом действует правило — 1 класс на 1 файл. В случае вложенных классов создаются дополнительные class файлы.
Если открыть любой class файл в hex редакторе, то файл начинается с «магических байт» — CAFEBABE, а дальше следует полезное содержимое файла.
Для того чтобы просмотреть содержимое, можно воспользоваться стандартной утилитой
javap может принимать много параметров. Давайте рассмотрим основные из них.
Смотреть будем на стандартном классе java.lang.Object
Без параметров
Выводится только основная информация по классам, методам и полям (приватные поля и методы не показываются)
Compiled from "Object.java" public class java.lang.Object < public java.lang.Object(); public final native java.lang.Class>getClass(); public native int hashCode(); public boolean equals(java.lang.Object); protected native java.lang.Object clone() throws java.lang.CloneNotSupportedException; public java.lang.String toString(); public final native void notify(); public final native void notifyAll(); public final native void wait(long) throws java.lang.InterruptedException; public final void wait(long, int) throws java.lang.InterruptedException; public final void wait() throws java.lang.InterruptedException; protected void finalize() throws java.lang.Throwable; static <>; >
-p
Показываются также приватные поля и методы
javap -p java.lang.Object
Compiled from "Object.java" public class java.lang.Object < public java.lang.Object(); private static native void registerNatives(); public final native java.lang.Class>getClass(); public native int hashCode(); public boolean equals(java.lang.Object); protected native java.lang.Object clone() throws java.lang.CloneNotSupportedException; public java.lang.String toString(); public final native void notify(); public final native void notifyAll(); public final native void wait(long) throws java.lang.InterruptedException; public final void wait(long, int) throws java.lang.InterruptedException; public final void wait() throws java.lang.InterruptedException; protected void finalize() throws java.lang.Throwable; static <>; >
-v
Показываются подробную информацию (verbose), такую, как размер стека и аргументов, версии и т.д.
javap -v java.lang.Object
Classfile jar:file Last modified 15.12.2018; size 1497 bytes MD5 checksum 074ebc688a81170b8740f1158648a3c7 Compiled from "Object.java" public class java.lang.Object minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Integer 999999 #2 = String #16 // @ #3 = String #38 // nanosecond timeout value out of range #4 = String #42 // timeout value is negative #5 = Utf8 ()I #6 = Utf8 ()Ljava/lang/Object; #7 = Utf8 ()Ljava/lang/String; #8 = Utf8 ()V #9 = Utf8 (I)Ljava/lang/String; #10 = Utf8 (J)V #11 = Utf8 (JI)V #12 = Utf8 (Ljava/lang/Object;)Z #13 = Utf8 (Ljava/lang/String;)V #14 = Utf8 #15 = Utf8 #16 = Utf8 @ #17 = Utf8 Code #18 = Utf8 Exceptions #19 = Utf8 LineNumberTable #20 = Utf8 Signature #21 = Utf8 SourceFile #22 = Utf8 StackMapTable #23 = Utf8 append #24 = Utf8 clone #25 = Utf8 equals #26 = Utf8 finalize #27 = Utf8 getClass #28 = Utf8 getName #29 = Utf8 hashCode #30 = Utf8 java/lang/Class #31 = Utf8 java/lang/CloneNotSupportedException #32 = Utf8 java/lang/IllegalArgumentException #33 = Utf8 java/lang/Integer #34 = Utf8 java/lang/InterruptedException #35 = Utf8 java/lang/Object #36 = Utf8 java/lang/StringBuilder #37 = Utf8 java/lang/Throwable #38 = Utf8 nanosecond timeout value out of range #39 = Utf8 notify #40 = Utf8 notifyAll #41 = Utf8 registerNatives #42 = Utf8 timeout value is negative #43 = Utf8 toHexString #44 = Utf8 toString #45 = Utf8 wait #46 = Class #30 // java/lang/Class #47 = Class #31 // java/lang/CloneNotSupportedException #48 = Class #32 // java/lang/IllegalArgumentException #49 = Class #33 // java/lang/Integer #50 = Class #34 // java/lang/InterruptedException #51 = Class #35 // java/lang/Object #52 = Class #36 // java/lang/StringBuilder #53 = Class #37 // java/lang/Throwable #54 = Utf8 ()Ljava/lang/Class; #55 = Utf8 ()Ljava/lang/Class<*>; #56 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder; #57 = NameAndType #29:#5 // hashCode:()I #58 = NameAndType #15:#8 // "":()V #59 = NameAndType #41:#8 // registerNatives:()V #60 = NameAndType #45:#10 // wait:(J)V #61 = NameAndType #27:#54 // getClass:()Ljava/lang/Class; #62 = NameAndType #28:#7 // getName:()Ljava/lang/String; #63 = NameAndType #44:#7 // toString:()Ljava/lang/String; #64 = NameAndType #43:#9 // toHexString:(I)Ljava/lang/String; #65 = NameAndType #15:#13 // "":(Ljava/lang/String;)V #66 = NameAndType #23:#56 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder; #67 = Methodref #46.#62 // java/lang/Class.getName:()Ljava/lang/String; #68 = Methodref #48.#65 // java/lang/IllegalArgumentException."":(Ljava/lang/String;)V #69 = Methodref #49.#64 // java/lang/Integer.toHexString:(I)Ljava/lang/String; #70 = Methodref #51.#57 // java/lang/Object.hashCode:()I #71 = Methodref #51.#59 // java/lang/Object.registerNatives:()V #72 = Methodref #51.#60 // java/lang/Object.wait:(J)V #73 = Methodref #51.#61 // java/lang/Object.getClass:()Ljava/lang/Class; #74 = Methodref #52.#58 // java/lang/StringBuilder."":()V #75 = Methodref #52.#63 // java/lang/StringBuilder.toString:()Ljava/lang/String; #76 = Methodref #52.#66 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; #77 = Utf8 Object.java < public java.lang.Object(); descriptor: ()V flags: ACC_PUBLIC Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 37: 0 public final native java.lang.Class>getClass(); descriptor: ()Ljava/lang/Class; flags: ACC_PUBLIC, ACC_FINAL, ACC_NATIVE Signature: #55 // ()Ljava/lang/Class<*>; public native int hashCode(); descriptor: ()I flags: ACC_PUBLIC, ACC_NATIVE public boolean equals(java.lang.Object); descriptor: (Ljava/lang/Object;)Z flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: aload_1 2: if_acmpne 9 5: iconst_1 6: goto 10 9: iconst_0 10: ireturn StackMapTable: number_of_entries = 2 frame_type = 9 /* same */ frame_type = 64 /* same_locals_1_stack_item */ stack = [ int ] LineNumberTable: line 149: 0 protected native java.lang.Object clone() throws java.lang.CloneNotSupportedException; descriptor: ()Ljava/lang/Object; flags: ACC_PROTECTED, ACC_NATIVE Exceptions: throws java.lang.CloneNotSupportedException public java.lang.String toString(); descriptor: ()Ljava/lang/String; flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: new #52 // class java/lang/StringBuilder 3: dup 4: invokespecial #74 // Method java/lang/StringBuilder."":()V 7: aload_0 8: invokevirtual #73 // Method getClass:()Ljava/lang/Class; 11: invokevirtual #67 // Method java/lang/Class.getName:()Ljava/lang/String; 14: invokevirtual #76 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 17: ldc #2 // String @ 19: invokevirtual #76 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 22: aload_0 23: invokevirtual #70 // Method hashCode:()I 26: invokestatic #69 // Method java/lang/Integer.toHexString:(I)Ljava/lang/String; 29: invokevirtual #76 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 32: invokevirtual #75 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 35: areturn LineNumberTable: line 236: 0 public final native void notify(); descriptor: ()V flags: ACC_PUBLIC, ACC_FINAL, ACC_NATIVE public final native void notifyAll(); descriptor: ()V flags: ACC_PUBLIC, ACC_FINAL, ACC_NATIVE public final native void wait(long) throws java.lang.InterruptedException; descriptor: (J)V flags: ACC_PUBLIC, ACC_FINAL, ACC_NATIVE Exceptions: throws java.lang.InterruptedException public final void wait(long, int) throws java.lang.InterruptedException; descriptor: (JI)V flags: ACC_PUBLIC, ACC_FINAL Code: stack=4, locals=4, args_size=3 0: lload_1 1: lconst_0 2: lcmp 3: ifge 16 6: new #48 // class java/lang/IllegalArgumentException 9: dup 10: ldc #4 // String timeout value is negative 12: invokespecial #68 // Method java/lang/IllegalArgumentException."":(Ljava/lang/String;)V 15: athrow 16: iload_3 17: iflt 26 20: iload_3 21: ldc #1 // int 999999 23: if_icmple 36 26: new #48 // class java/lang/IllegalArgumentException 29: dup 30: ldc #3 // String nanosecond timeout value out of range 32: invokespecial #68 // Method java/lang/IllegalArgumentException."":(Ljava/lang/String;)V 35: athrow 36: iload_3 37: ifle 44 40: lload_1 41: lconst_1 42: ladd 43: lstore_1 44: aload_0 45: lload_1 46: invokevirtual #72 // Method wait:(J)V 49: return StackMapTable: number_of_entries = 4 frame_type = 16 /* same */ frame_type = 9 /* same */ frame_type = 9 /* same */ frame_type = 7 /* same */ LineNumberTable: line 447: 0 line 448: 6 line 451: 16 line 452: 26 line 456: 36 line 457: 40 line 460: 44 line 461: 49 Exceptions: throws java.lang.InterruptedException public final void wait() throws java.lang.InterruptedException; descriptor: ()V flags: ACC_PUBLIC, ACC_FINAL Code: stack=3, locals=1, args_size=1 0: aload_0 1: lconst_0 2: invokevirtual #72 // Method wait:(J)V 5: return LineNumberTable: line 502: 0 line 503: 5 Exceptions: throws java.lang.InterruptedException protected void finalize() throws java.lang.Throwable; descriptor: ()V flags: ACC_PROTECTED Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 555: 0 Exceptions: throws java.lang.Throwable static <>; descriptor: ()V flags: ACC_STATIC Code: stack=0, locals=0, args_size=0 0: invokestatic #71 // Method registerNatives:()V 3: return LineNumberTable: line 41: 0 line 42: 3 > SourceFile: "Object.java"
В IDEA имеет встроенные средства для просмотра:
Что содержит .class файл
Структура .class файла (документация):
ClassFile
u1 , u2 , and u4 — размер полей
cp_info , field_info , method_info , attribute_info — специальные таблицы, о которых рассказывается ниже
- magic — магическая константа (0xCAFEBABE), мы о ней уже говорили.
- minor_version, major_version — версия формата .class файла (смотреть ниже)
- constant_pool_count и constant_pool — длина пула констант и сам пул констант ( Пул констант — таблица для записи различный текстовых констант, имен интерфейсов, классов, полей и другие константы, на которые в дальнейшем будут ссылки в процессе выполнения, раздел Constant pool при выводе javap -v)
- access_flags — набор флагов (public, abstract, enum и т.д.)
- this_class — ссылка на пул констант, которая определяет данный класс
- super_class — ссылка на пул констант, которая определяет родительский класс
- interfaces_count и interfaces — количество интерфейсов, которые реализуют класс и ссылки на пул констант для этих интерфейсов
- fields_count и fields — информация по полям
- methods_count и methods — информация по методам
- attributes_count и attributes — информация по атрибутам
Версии class файлов
Довольно часто можно увидеть ошибку, если запускать на более ранней версии jvm: Exception in thread «main» java.lang.UnsupportedClassVersionError: . has been compiled by a more recent version of the Java Runtime (class file version 55.0), this version of the Java Runtime only recognizes class file versions up to 52.0 . Здесь 55.0 и 52.0 — версии class файлов.
Чтобы понять, какие версии class файлов какая jvm поддерживает, можно воспользоваться таблицей из документации:
Байткод (Bytecode)
Создавать игры бывает весело, но не очень то и легко. Современные игры требуют гигантской сложнейшей кодовой базы. Производители консолей и владельцы магазинов приложений постоянно ужесточают требования к качеству и даже единственный баг может не дать вашей игре выйти на рынок.
Я работал над играми, в которых было шесть миллионов строк кода на C++ . Для сравнения код, управляющий марсоходом Mars Curiosity примерно в половину меньше.
В то же время мы хотим выжать из существующего железа всю производительность до последней капли. Игры нагружают железо как никакие другие программы, и нам нужно постоянно заниматься оптимизацией, чтобы просто угнаться за конкурентами.
Чтобы соответствовать этим высоким требованиям стабильности и производительности, мы используем тяжеловесные языки типа C++ , в которых есть достаточная низкоуровневая выразительность чтобы напрямую работать с железом и богатая система типизации, предотвращающая или хотя бы ограничивающая появление багов.
Мы гордимся своими навыками работы с ними, но у всего есть своя цена. Чтобы стать профессиональным программистом нужно потратить немало лет на учение, после которых вы сможете справиться со сложностью собственной кодовой базы. Время сборки больших игр может варьироваться от «можно пойти попить кофе» до «пожарьте кофе бобы, размелите их вручную, сварите эспрессо, приготовьте молоко и потренируйтесь в приготовлении латте.»
Помимо всех этих сложностей в играх обязательно должен присутствовать еще один компонент: веселье. Игроки требуют игровой процесс, который будет и новаторским и отлично сбалансированным. Для этого нужно множество итераций, но даже небольшая настройка требует от программиста залазить в низкоуровневый код и потом ожидать перекомпиляцию, что в результате во многом убивает творческий процесс.
Война заклинаний!
Скажем, мы работаем над магическим файтингом. Двое волшебников борются между собой с помощью заклинаний, пока не останется только победитель. Мы можем определить эти заклинания в коде, но это будет означать что каждый раз когда нужно будет настроить их параметры, придется беспокоить программиста. Если геймдизайнер хочет немного изменить числа чтобы попробовать что получится, для этого придется перекомпилировать всю игру, перезагружать ее и повторять бой заново.
Как часто бывает с современными играми, нам нужно иметь возможность обновлять игру после продажи чтобы исправлять баги и добавлять новый контент. Если заклинание будет жестко закодированным, то для обновления нужно будет патчить сам исполнимый файл игры.
Давайте пойдем дальше и представим себе что мы хотим поддерживать моды. Мы хотим дать игрокам возможность создавать собственные заклинания. Если они будут находиться в коде, это значит что каждому моддеру нужно иметь всю цепочку инструментов для сборки игры и нам нужно выкладывать свои исходники. Еще хуже то, что если в заклинании будет баг, он будет способен обрушить всю игру целиком на машине игрока.
Данные > Код
Понятно, что наша реализация на языке программирования нам подходит не очень. Нам нужно выделить для заклинаний отдельную от игры песочницу. Нам нужно, чтобы они легко редактировались и перезагружались, а также были физически отделены от остальной части исполнимого файла.
Не знаю как вам, а мне это напоминает данные. Если мы сможем определить наше поведение в отдельном файле данных, которые движок игры будет каким-то образом загружать и «выполнять» — это и будет то что нам нужно.
Нужно определиться с тем что значит «выполнить» данные. Как несколько байтов из файла можно превратить в поведение. Есть несколько способов. Я думаю нам легче будет оценить сильные и слабые стороны шаблона, если мы сравним его с другим шаблоном: Интерпретатор (Interpreter) GoF .
Шаблон Интерпретатор
Я мог бы написать об этом шаблоне целую главу, но до меня это сделали четыре известные вам мужика. Вместо этого я ограничусь совсем кратким его описанием. Он начинается с языка — я имею в виду языка программирования — который вы хотите выполнять. Скажем он будет поддерживать математические выражения следующего вида:
(1 + 2) * (3 — 4)
Мы берем каждый из фрагментов этого выражения и превращаем его в соответствии с правилами языка в объект. Числовые литералы будут объектами:
По идее, это просто небольшая обертка над сырыми значениями. Операторы тоже будут объектами и у них будут ссылки на свои операнды. Если учитывать родителей и наследников, это выражение превращается в небольшое дерево объектов:
Что это за «магия»? Очень просто: парсинг. Парсер получает строку символов и превращает ее в абстрактное синтаксическое дерево, т.е. коллекцию объектов, представляющих грамматическую структуру текста.
Как только у нас будет парсер — считай пол компилятора готово.
Шаблон интерпретатор не о том как создавать это дерево. Он о том как его выполнять. Действует он весьма разумно. Каждый объект в дереве — это выражение или подвыражение. В полностью объектно-ориентированной манере мы позволяем выражениям вычислять самих себя.
Для начала определим базовый интерфейс, реализуемый всеми выражениями.
class Expression < public: virtual ~Expression() <> virtual double evaluate() = 0; >;
Далее определяем класс, реализующий это для всех типов выражений в словаре нашего языка. Самые простые из них — это числа.
class NumberExpression : public Expression < public: NumberExpression(double value) : value_(value) <> virtual double evaluate() < return value_; > private: double value_; >;
Выражение числовой литерал вычисляет свое значение. Сложение и умножение немного сложнее, потому что содержат подвыражения. До того как они смогут вычислить свое значение, они должны рекурсивно вычислить все подвыражения. Примерно таким образом:
Я уверен, что вы догадаетесь как выглядит реализация умножения.
class AdditionExpression : public Expression < public: AdditionExpression(Expression* left, Expression* right) : left_(left), right_(right) <> virtual double evaluate() < // Вычисляем операнды. double left = left_->evaluate(); double right = right_->evaluate(); // Складываем их. return left + right; > private: Expression* left_; Expression* right_; >;
Довольно изящно, не правда ли? Всего несколько простых классов и теперь у нас есть возможность вычислять достаточно сложные арифметические выражения. Нам просто нужно будет создать правильные объекты и корректно их подключить.
Это отличный, простой шаблон, но имеющий значительные недостатки. Посмотрите на иллюстрацию. Что вы видите? Кучу прямоугольников и кучу стрелок между ними. Код представляется как развесистое фрактальное дерево мелких объектов. Это порождает некоторые неприятные последствия:
Ruby был реализован именно таким образом около 15 лет назад. В версии 1.9 он перешел на байткод по типу того, что описан в этой главе. Смотрите сколько времени я вам сэкономил!
- Загрузка с диска требует создания экземпляров и связывание множества мелких объектов.
- Эти объекты и указатели между ними занимают кучу памяти. На 32-битной машине даже такое маленькое выражение занимает не меньше 68 байт, не считая выравнивания.
Если вы играете дома, не забудьте принять во внимание виртуальную таблицу указателей (vtable pointers).
- Путешествие по указателям подвыражений убивает данные в кеше. Да и вообще все вызовы виртуальных методов сеют панику в кеше инструкций.
См. главу Локализация данных (Data Locality), где подробнее описано что такое кеш и как он влияет на производительность.
Если соединить все это вместе, то что получится? S-L-O-W (медленно). Вот почему большинство языков программирования, которые мы используем не основаны на шаблоне Интерпретатор. Он просто слишком медленный и требует слишком много памяти.
Машинный код, виртуальный
Вспомним нашу игру. Когда мы ее запускаем, компьютер игрока не пытается построить в реальном времени словарь C++ кода. Вместо этого у нас есть машинный код, который выполняется на процессоре. В чем же особенность машинного кода?
- Он компактен. Это цельное, сплошное скопление бинарных данных, в котором зазря не тратится ни единый бит.
- Он линеен. Инструкции собраны вместе и выполняются одна за другой. Никаких прыжков по памяти (если вы конечно не управляете порядком выполнения).
- Он низкоуровневый. Каждая инструкция выполняет относительно минимальную вещь и более сложное поведение получается путем их комбинирования.
- Он быстрый. Следствием всего этого (ну и еще конечно потому, что это реализовано в железе) является то, что машинный код выполняется со скоростью ветра.
Звучит здорово, но ведь мы не собираемся писать настоящий машинный код для нашего заклинания. Позволять пользователю писать машинный код, который мы будем выполнять — это крайне небезопасно. Что нам нужно — так это некий компромисс между машинным кодом и безопасностью шаблона Интерпретатор.
Что если вместо загрузки настоящего машинного кода и прямого его выполнения, мы определим наш собственный виртуальный машинный код? Далее мы просто напишем небольшой эмулятор для нашей игры. Он будет похож на машинный код — компактный, линейный, сравнительно низкоуровневый, но будет интерпретироваться нашей игрой и значит его можно будет выполнять в режиме песочницы.
Вот почему многие игровые консоли и iOS не позволяют программам выполнять машинный код, загружаемый или генерируемый во время выполнения программы. Это не очень хорошо, потому что самые быстрые реализации языков именно так и работают. Они содержат в себе компилятор на лету (just-in-time) или JIT, преобразующий язык в оптимизированный машинный код на лету.
Наш маленький эмулятор будет называться виртуальной машиной (или «VM» если коротко), а наш синтетический машинный код, который на ней выполняется будет называться байткодом. Он обладает гибкостью и простотой использования описания вещей с помощью данных, но обладает лучшей производительностью чем высокоуровневые реализации типа шаблона Интерпретатор.
В программистских кругах «виртуальная машина» и «интерпретатор» — синонимы и я использую эти слова во взаимозаменяемом значении. Но когда мы говорим о шаблоне Интерпретатор от банды четырех, я специально пишу шаблон, чтобы было понятно о чем я говорю.
Звучит довольно сложно. Оставшуюся часть главы я посвящу тому, чтобы продемонстрировать, что если ограничиться только самыми главными функциями, это вполне посильная задача. Даже если вы сами не будете использовать этот шаблон, вы будете лучше понимать Lua и другие языки, основанные на этом шаблоне.
Шаблон
Набор инструкций определяет низкоуровневые операции, которые можно выполнить. Они кодируются в виде последовательности байтов. Виртуальная машина выполняет эти инструкции по одной за раз, используя стек промежуточных значений. Комбинируя инструкции можно определить сложное высокоуровневое поведение.
Как использовать
Это самый сложный шаблон из описанных в книге и его не так уж просто вставить в игру. Использовать его стоит только если вам нужно определить очень много разнообразного поведения и язык, на котором вы пишете игру не слишком для этого подходит потому что:
- Он слишком низкоуровневый и программировать на нем сложно или чревато ошибками.
- Итеративная работа с ним получается слишком медленной из-за долгой компиляции или других проблем со сборкой.
- Он слишком доверителен. Если вы хотите быть уверенными что определенное поведение не обрушит игру, вам нужно организовать для него песочницу, отдельную от остальной кодовой базы.
Конечно, все это подходит практически к любой игре. Кто же не хочет более быстрых циклов или большей безопасности? К сожалению, ничто не достается даром. Байткод медленнее настоящего, так что в критических для производительности частях движка игры его применять не стоит.
Имейте в виду
Есть что-то притягательное в идее создания собственного языка или системы в системе. Я приведу здесь минимальный пример, но в реальном мире такие вещи обычно разрастаются подобно виноградной лозе.
Для меня игровая разработка сама по себе привлекательна. В любом случае я создаю виртуальное пространство для других людей, в котором они могут играть и заниматься созиданием.
Каждый раз, когда я вижу как кто-то реализует язык или систему скриптов, они говорят «Не волнуйся, она будет совсем маленькой». А потом они неизбежно начинают добавлять все новые и новые маленькие возможности, пока не получится полноценный язык. И в отличие от других языков, он растет прямо там, где появился как некий живой организм и в результате его архитектура получается подобной городским трущобам.
Для примера можете посмотреть любой язык шаблонов.
Конечно, ничего плохого в создании полноценного языка нет. Но только если вы делаете это сознательно. Иначе не забывайте контролировать возможности своего байткода. Наденьте на него поводок, прежде чем он от вас не убежал.
Вам нужен интерфейс для байткода (front-end)
У низкоуровневых инструкций байткода впечатляющая производительность, но бинарный формат — это не то что хотели бы видеть ваши пользователи. Одна из причин, почему мы убираем описание поведения из кода — это потому что мы хотим его описывать на более высоком уровне. Если C++ — слишком низкий уровень, позволяющий писать практически на уровне ассемблера — даже ваша собственная архитектура — это еще не улучшение!
Также, как в шаблоне Интерпретатор от банды четырех, мы подразумеваем, что у нас есть способ генерации байткода. Обычно пользователи описывают поведение на довольно высоком уровне и потом специальная утилита превращает результат в байткод, понятный виртуальной машине. Другими словами компилятор.
Оспаривать это утверждение берется почтенная игра RoboWar. В этой игре игрок пишет маленькую программу для управления роботом на языке, очень похожем на ассемблер и с набором инструкций, подобным здесь описываемому.
С ее помощью я впервые познакомился с ассемблеро-подобными языками.
Я знаю что звучит пугающе. Вот почему я об этом и упоминаю. Если у вас нет возможности писать дополнительный инструментарий — значит байткод не для вас. Но как вы скоро увидите, все не так уж и плохо.
Вам будет не хватать вашего отладчика
Программирование — это сложно. Мы знаем чего хотим от машины, но не всегда правильно сообщаем ей об этом — т.е. допускаем появление багов. Для того чтобы их устранять мы вооружаемся кучей инструментов, помогающих нам понять что в коде работает неправильно и как это исправить.
У нас есть отладчик, статический анализатор, декомпилятор и т.д. Все эти инструменты разработаны для каких-либо существующих языков: либо машинного кода, либо для чего-то более высокоуровневого.
Когда вы определяете собственную виртуальную машину для байткода, эти инструменты становятся для вас недоступными. Конечно вы можете отлаживать саму виртуальную машину. Но таким образом вы ведь видите только что делает сама виртуальная машина, а не байткод, который она интерпретирует. И конечно она не позволит вам увидеть что делается с байткодом в его исходной высокоуровневой форме, из которой он компилировался.
Если поведение, которое мы определяем довольно простое, вы вполне можете обойтись без всяких дополнительных инструментов. Но как только объем контента будет расти, думайте о том что вам придется потратить время на инструменты, помогающие пользователям видеть что их байткод делает. Они могут не поставляться в комплекте с игрой, но они могут быть критичными для того чтобы вы вообще смогли выпустить игру.
Конечно, если вы хотите чтобы ваша игра была модифицируемой, тогда вам придется оставить в игре эти возможности и это только увеличивает их важность.
Пример кода
После предыдущих нескольких разделов вы наверное будете удивлены тем насколько прямолинейна реализация. Для начала нам нужно сформировать набор инструкций для виртуальной машины. Прежде чем начать думать о байткоде и прочем, давайте подумаем о нем как об API .
Магический API
Если мы описываем заклинания обычным C++ кодом, какой API нам нужен для его вызова? Что за базовые операции определены в движке игры, с помощью которых можно определять заклинания?
Большинство заклинаний в конце концов меняют одну из статистик волшебника, так что мы начнем с функций для такого изменения:
void setHealth(int wizard, int amount); void setWisdom(int wizard, int amount); void setAgility(int wizard, int amount);
Первый параметр указывает на какого волшебника мы влияем, например 0 для игрока, а 1 для его противника. Таким образом можно, например, лечить своего волшебника и наносить урон противнику. С помощью всего трех этих методов можно описать очень много магических эффектов.
Если заклинание будет просто тихо изменять состояния, игровая логика будет работать, но играть будет скучно до слез. Давайте это исправим:
void playSound(int soundId); void spawnParticles(int particleType);
Эти функции не влияют на геймплей, но улучшают восприятие игры. Мы можем добавить тряску камеры, анимации и т.д., но для начала хватит и этого.
Набор магических инструкций
Теперь посмотрим как мы можем превратить программный API в нечто, что можно контролировать из данных. Начнем с малого, а потом будем дорабатывать то что получается. Сейчас мы считаем что в методы никаких параметров не передается. Скажем метод set___() всегда будет влиять на волшебника игрока и устанавливать параметр в максимальное значение. Аналогично операции спецэффектов (FX) будут проигрывать жестко заданный звук или систему частиц.
Теперь мы можем описать наше заклинание как набор инструкций. Каждая указывает какую операцию нужно выполнить. Перечислим их:
enum Instruction < INST_SET_HEALTH = 0x00, INST_SET_WISDOM = 0x01, INST_SET_AGILITY = 0x02, INST_PLAY_SOUND = 0x03, INST_SPAWN_PARTICLES = 0x04 >;
Чтобы закодировать заклинание в данных, мы просто сохраняем массив со значениями enum. У нас всего несколько примитивов так что диапазон значений enum помещается в один байт. Это значит что наше заклинание — это всего лишь список байтов — следовательно «байткод».
Некоторые виртуальные машины используют больше чем один байт для каждой инструкции и декодируют их по более сложным правилам.
Тем не менее одного байта достаточно для Java Virtual Machine и для Microsoft’s Common Language Runtime, на котором основана платформа .NET и нас вполне устраивает работа этих машин.
Чтобы выполнить одну инструкцию, мы смотрим что у нас за примитив и вызываем соответствующий метод API :
switch (instruction) < case INST_SET_HEALTH: setHealth(0, 100); break; case INST_SET_WISDOM: setWisdom(0, 100); break; case INST_SET_AGILITY: setAgility(0, 100); break; case INST_PLAY_SOUND: playSound(SOUND_BANG); break; case INST_SPAWN_PARTICLES: spawnParticles(PARTICLE_FLAME); break; >
Таким образом, наш интерпретатор формирует мост между мирами кода и данных. Мы можем поместить его в небольшую обертку виртуальной машины, которая выполняет все заклинание следующим образом:
class VM < public: void interpret(char bytecode[], int size) < for (int i = 0; i < size; i++) < char instruction = bytecode[i]; switch (instruction) < // Случаи для каждой инструкции. > > > >;
Теперь у нас есть своя виртуальная машина. К сожалению, она не очень то и гибкая. Мы не можем описать заклинание которое действует на персонаж противника или понижает статистику. И звук мы можем играть всего только один!
Чтобы получить что-то похожее на настоящий язык, нам нужно добавить параметры.
Машина стеков
Чтобы выполнить сложное выражение с вложениями, нужно начинать с подвыражений. Вычисляем их и передаем результаты вверх в качестве аргументов содержащих их выражений до тех пор, пока все выражение не будет вычислено.
Шаблон Интерпретатор моделирует это явно в виде дерева вложенных объектов, но нас интересует скорость и плоский список инструкций. Мы все равно хотим чтобы результат подвыражений попадал в правильное ограничивающее выражение. Но так как наши данные теперь плоские, чтобы этим управлять нам нужно использовать порядок инструкций. Мы будем делать это также как работает процессор: с помощью стека.
Такая архитектура без всякой фантазии названа стековой машиной. Языки программирования типа Forth [1]), PostScript [2] и Factor [3]) предоставляют эту модель пользователю напрямую.
class VM < public: VM() : stackSize_(0) <> // Other stuff. private: static const int MAX_STACK = 128; int stackSize_; int stack_[MAX_STACK]; >;
Виртуальная машина содержит внутренний стек значений. В нашем примере единственный тип значений, с которыми работают наши инструкции — это числа. Поэтому мы можем использовать простой массив целых чисел. Как только любому биту данных нужно будет быть переданным из одной инструкции в другую — это будет происходить через стек.
Как следует из имени, значения могут помещаться и извлекаться из стека. Добавим методы для этих операций:
class VM < private: void push(int value) < // Поверяем переполнение стека. assert(stackSize_ < MAX_STACK); stack_[stackSize_++] = value; >int pop() < // Проверяем что стек не пустой. assert(stackSize_ > 0); return stack_[--stackSize_]; > // Другие вещи. >;
Когда инструкции требуется получить параметры, она берет их из стека:
switch (instruction) < case INST_SET_HEALTH: < int amount = pop(); int wizard = pop(); setHealth(wizard, amount); break; > case INST_SET_WISDOM: case INST_SET_AGILITY: // Аналогично, как сверху. case INST_PLAY_SOUND: playSound(pop()); break; case INST_SPAWN_PARTICLES: spawnParticles(pop()); break; >
Чтобы извлечь значение на стек, нам нужна еще одна инструкция: литерал. Она представляет собой сырое целое значение. Но откуда берется это значение? Как нам избежать здесь бесконечной регрессии вниз?
Можно воспользоваться тем фактом, что наш поток инструкций — это всего лишь последовательность байт: мы можем добавить число прямо в поток инструкций. Определим еще один тип инструкций для численных литералов:
case INST_LITERAL: < // Считываем следующий байт из байткода. int value = bytecode[++i]; push(value); break; >
Здесь я просто считываю один байт для получения значения числа чтобы не заморачиваться с кодом, считывающим многобайтные целые значения, но в настоящей реализации вам понадобятся литералы, способные покрывать полный диапазон численных типов.
Она считывает следующий байт в потоке байткода как число и помещает его на стек.
Давайте сформируем строку из нескольких инструкций и посмотрим как интерпретатор будет ее выполнять чтобы понять как работает стек. Начнем с пустого стека и интерпретатора, указывающего на первую инструкцию.
Первой выполняется инструкция INST_LITERAL . Она считывает следующий байт из баткода ( 0 ) и помещает его в стек.
Далее выполняется второй INST_LITERAL . Он считывает 10 и тоже помещает в стек.
И наконец вызывается INST_SET_HEALTH . Она берет со стека значение 10 и сохраняет его в amount . Далее берет со стека 0 и сохраняет в wizard . После этого происходит вызов setHealth() с этими параметрами.
Та-да! У нас получилось заклинание, устанавливающее здоровье волшебника игрока в значение десять единиц. Мы получили достаточную гибкость для того, чтобы устанавливать здоровье любого из волшебников в любое значение. Аналогично мы можем проигрывать разные звуки и порождать частицы.
Но. это все еще похоже на формат данных. Мы не можем, например, установить здоровье волшебника равным половине его мудрости. А нашим дизайнерам было бы интересно указывать правила для заклинаний, а не просто значения.
Поведение = композиция
Если думать о нашей маленькой виртуальной машине как о языке программирования, все что он поддерживает — это всего несколько встроенных функций и константных параметров для них. Чтобы описывать на байткоде поведение, нам не хватает композиции.
Нашим дизайнерам нужно иметь возможность создавать выражения, комбинирующие различные значения интересным образом. Простой пример — это возможность модифицировать характеристики персонажа на определенное значение, а не устанавливать в конкретное значение.
Для этого нам нужно учитывать текущее значение характеристики. У нас есть инструкции для установки значений. А теперь нам нужно добавить инструкции для чтения значений:
case INST_GET_HEALTH: < int wizard = pop(); push(getHealth(wizard)); break; > case INST_GET_WISDOM: case INST_GET_AGILITY: // Ну вы поняли.
Как вы видите, они работают со стеком в обе стороны. Они берут со стека параметр чтобы узнать для какого волшебника нужно получить характеристику, получают это значение и помещают его обратно в стек.
Теперь мы можем написать заклинание, которое будет копировать характеристики. Мы можем создать заклинание, которое будет устанавливать ловкость волшебника равной его мудрости или чары, которые будут приравнивать здоровье одного волшебника здоровью другого.
Уже лучше, но все равно ограничения остаются. Нам нужна арифметика. Пришло время для нашей маленькой виртуальной машины научиться складывать 1+1 . Добавим еще несколько инструкций. Теперь вам уже наверное проще будет понять как они работают. Просто покажу сложение:
case INST_ADD: < int b = pop(); int a = pop(); push(a + b); break; >
Как и остальные наши инструкции, она берет со стека пару значений, выполняет с ними работу и помещает результат обратно в стек. До сих пор каждая новая наша инструкция давала нам фиксированный прирост в выразительности, а теперь мы сделали большой шаг. Это не очевидно, но теперь мы можем обрабатывать любые типы сложных, многоуровневых вычислений.
Перейдем к более сложному примеру. Скажем, мы хотим создать заклинание, которое будет устанавливать значение здоровья волшебника равным среднему арифметическому между его ловкостью и мудростью. Вот его код:
setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);
Вы можете подумать, что нам нужна инструкция для обработки явной группировки, которая управляет подчиненностью в данном выражении, но стек косвенно уже все это поддерживает. Вот как мы стали бы вычислять значение вручную:
- Получаем текущее значение здоровья волшебника и сохраняем его.
- Получаем ловкость волшебника и сохраняем ее.
- Делаем тоже самое с мудростью.
- Складываеп последние два значения и запоминаем результат.
- Делим его на два, сохраняем результат.
- Вспоминаем значение здоровья волшебника и прибавляем к нему результат.
- Берем результат и устанавливаем в качестве текущего значения здоровья волшебника.
Обратили внимания на все эти «сохраним» и «вспомним»? Каждое «сохраним» — это помещение в стек (push), а каждое «вспомним» — это получение значения со стека (pop). Это значит что мы достаточно просто можем перевести наши действия в байткод. Например, первая строка для получения текущего значения здоровья волшебника выглядит так:
LITERAL 0 GET_HEALTH
Этот фрагмент байткода помещает значение здоровья волшебника в стек. Если мы механически транслируем каждую строку таким образом, мы получим фрагмент байткода, вычисляющий наше оригинальное выражение. Чтобы почувствовать как комбинируются инструкции, я продемонстрирую вам это ниже.
Чтобы показать как стек меняется со временем, мы проследим за примером выполнения, в котором у волшебника здоровье будет равно 45 , ловкость — 7 и мудрость — 11 . После выполнения каждой инструкции видно как выглядит стек и приведен небольшой комментарий.
LITERAL 0 [0] # индекс волшебника LITERAL 0 [0, 0] # индекс волшебника GET_HEALTH [0, 45] # getHealth() LITERAL 0 [0, 45, 0] # индекс волшебника GET_AGILITY [0, 45, 7] # getAgility() LITERAL 0 [0, 45, 7, 0] # индекс волшебника GET_WISDOM [0, 45, 7, 11] # getWisdom() ADD [0, 45, 18] # сложение ловкости и мудрости LITERAL 2 [0, 45, 18, 2] # Divisor DIVIDE [0, 45, 9] # вычисление среднего ловкости и мудрости ADD [0, 54] # добавление среднего к текущему значению SET_HEALTH [] # установка здоровья равным результату
Если вы посмотрите на состояние стека на каждом шаге, вы увидите каким магическим образом мы управляем потоком инструкций. Мы помещаем в стек 0 в качестве индекса волшебника в начале и он таки будет болтаться в самом низу до тех пор пока не понадобится нам в самом конце для инструкции SET_HEALTH .
Возможно эпитет «магия» здесь даже недостаточен.
Виртуальная машина
Я могу и дальше добавлять новые и новые инструкции, но думаю на этом стоит остановиться. На данный момент у нас уже есть маленькая виртуальная машина, позволяющая довольно свободно определять поведение в простом, компактном формате данных. Хотя «байткод» и «виртуальная машина» и звучат пугающе, вы можете убедиться сами что все что здесь есть — это стек, цикл и управляющие переключатели.
Помните нашу исходную задачу о том что поведение нужно определять в режиме песочницы? Теперь когда вы знаете как вы реализовали виртуальную машину мы эту задачу выполнили. Байткод не может сделать ничего злонамеренного и не может выбраться за пределы своего места в движке, так как общаться с остальным кодом он может только через свои инструкции.
Мы сами контролируем сколько памяти виртуальная машина использует и какой у нее размер стека. Поэтому нам нужно тщательно следить за тем чтобы не было переполнения. Мы даже можем управлять тем сколько времени она использует. В нашем цикле инструкций мы можем подсчитать сколько всего выполнили и прерваться когда достигнем установленного предела.
Управлять временем выполнения в нашем примере необязательно, потому что у нас нет никаких инструкций для организации циклов. Мы можем ограничить время выполнения только ограничением общего размера байткода. Из этого кстати следует что наш байткод не является полным по Тьюрингу.
Осталась всего одна проблема: само создание байткода. До сих пор мы составляли псевдокод и компилировали его вручную. Если у вас конечно нет кучи свободного времени, на практике так работать не получится.
Утилита для волшебства
Нашей изначальной задачей было получение высокоуровневого способа задания поведения, но в результате у нас получилось нечто еще более низкоуровневое чем C++ . У этого решения есть нужная нам производительность и безопасность, которые мы хотели, но никакого удобства работы для дизайнера.
Чтобы устранить это препятствие нам нужен инструмент. Нам нужна программа, позволяющая пользователям определять поведение для заклинания на высоком уровне, а затем генерировать низкоуровневый байткод для стековой машины.
Звучит еще страшнее, чем создание виртуальной машины. Многие программисты изучали компиляторы в качестве дисциплины в колледже и начинают испытывать симптомы посттравматического синдрома от одного вида книжки с драконом или слов «lex)» и «yacc».
По правде говоря, компиляция текстоподобного языка не настолько страшная задача, но пожалуй чересчур обширная, чтобы говорить о ней подробно в этой главе. Однако вам это может и не понадобиться. Я ведь говорил об инструменте — т.е. компиляторе, исходниками для которого должен быть текстовый файл.
Я скорее призываю вас к созданию инструмента с графическим интерфейсом, в котором люди смогут определить нужное им поведение, даже если они не слишком технически подкованы. Написание текста, свободного от синтаксических ошибок слишком сложно для неподготовленных людей, у которых нет опыта когда на них годами ругается компилятор.
Вместо этого вы можете создавать приложение, которое позволит пользователю «скриптовать», кликая и перетаскивая маленькие кирпичики или выбирая варианты из меню или работать с другими подобными объектами, удобными для определения поведения.
Это хорошо тем, что, пользуясь только пользовательским интерфейсом, невозможно создать «некорректную» программу. Вместо того чтобы извергать на пользователей сообщения с ошибками, вы можете блокировать кнопки или ограничивать вводимые значения чтобы создаваемые скрипты всегда были корректными.
Хочу еще раз напомнить насколько важна обработка ошибок. Как программисты мы привыкли рассматривать человеческие ошибки как недопустимые недостатки, которые необходимо в себе искоренять.
Чтобы создать систему, с которой пользователю было бы приятно работать, вам придется принять его человечность, включая склонность ошибаться. Люди только тем и занимаются что делают ошибки и это фундаментальная основа творческого процесса. Корректная их обработка с такими полезными функциями как отмена позволяет пользователям быть более креативными и делать работу лучше.
Таким образом, вам не нужно разрабатывать словарь и писать парсер для маленького языка. Но я знаю что не всем по душе программирование пользовательских интерфейсов. Ну значит в таком случае у меня нет для вас хороших новостей.
В конце концов шаблон ведь о том чтобы иметь возможность описывать поведение в дружественной пользователю высокоуровневой форме. Вам нужно разнообразить игровой опыт. Чтобы эффективно выполнять поведение вам нужно преобразовывать результат в низкоуровневую форму. Это серьезная работа, но при должном усилии она окупится.
Архитектурные решения
Я старался сохранить эту главу настолько простой, насколько это возможно, но все, что мы на самом деле сделали — это создали новый язык. Это прекрасное открытое пространство для творчества. Исследовать его — громадное удовольствие, но при этом нужно не забывать и о самой игре.
Но раз уж эта глава получилась самой длинной в книге — значит с задачей я не справился.
Как инструкции будут получать доступ к стеку?
Виртуальные машины с байткодом делятся на два основных подвида: основанные на стеке (stack-based) и основанные на регистрах (register-based). В виртуальных машинах, основанных на стеках, инструкции всегда работают с верхом стека, как в наших примерах кода. Например, INST_ADD берет сверху два значения, складывает их и помещает на верх стека результат.
У виртуальных машин на основе регистров тоже есть стек. Единственное отличие заключается в том что инструкции могут считывать из него значения на более глубоком уровне. Вместо того чтобы INST_ADD всегда брала операнды сверху стека, у нее в байткоде хранится два индекса, указывающие откуда брать операнды.
- Виртуальные машины на основе стека:
- Инструкции минимального размера. Так как каждая инструкция берет аргументы с верха стека, для этого не нужно декодировать никаких данных. Это значит что каждая инструкция довольно мала и обычно занимает один байт.
- Генерация кода проще. Когда вы будете писать компилятор или инструмент для вывода байткода, вы увидите что генерировать байткод для стека проще. Так как каждая инструкция работает с вершиной стека, вам нужно просто вывести инструкции в правильном порядке, чтобы между ними передавались параметры.
- У вас будет больше инструкций. Каждая инструкция видит только верх стека. Это значит что для того чтобы сгенерировать код типа a = b + c , вам нужно будет использовать отдельные инструкции для помещения b и c наверх стека, выполнить операцию и потом поместить результат в a .
Lua не определяет собственную форму байткода и меняет ее от версии к версии. То, что я написал выше — справедливо для Lua 5.1 . Прекрасное введение в Lua можно прочитать здесь.
Так что же вам выбрать? Я рекомендую остановиться на варианте виртуальной машины со стеком. Ее легче реализовывать и гораздо проще генерировать код для нее. Виртуальная машина на базе регистров получила репутацию более быстрой после того как на нее перешла Lua, но это все сильно зависит от ваших инструкций и многих других деталей реализации виртуальной машины.
Какие инструкции у нас бывают?
Ваш набор инструкций определяет границы, в рамках которых мы можем выражаться в байткоде и сильно влияет на производительность вашей виртуальной машины. Вот список основных типов инструкций, которые вам могут понадобиться:
- Внешние примитивы. Это те, которые выходят за пределы виртуальной машины, попадают в другие части игры и отвечают за видимые пользователем вещи. Они отвечают за типы реального поведения, которое можно выразить в байткоде. Без них ваша виртуальная машина не сможет делать ничего кроме пустого прожигания циклов процессора.
- Внутренние примитивы. Это значения, которыми мы манипулируем внутри самой виртуальной машины типа литералов, арифметики, операторов сравнения и инструкций для жонглирования стеком.
- Порядок управления. В нашем примере мы такого не видели, но если вам нужно добиться императивного поведения, условного выполнения или контролировать выполнение инструкций с помощью циклов и выполнять инструкции больше одного раза, вам нужны инструкции контроля над порядком выполнения. На самом низком уровне байткода это реализуется очень просто — с помощью переходов (jumps). В нашей инструкции цикла будет присутствовать индекс, чтобы следить за тем, где в байткоде мы находится. Все переходы выполняются изменением этой переменной и присваиванием нового значения, где мы теперь находимся. Другими словами это goto . С его помощью можно устроить любые условные переходы более высокого уровня.
- Абстрактные. Если ваши пользователи начинают объявлять в данных слишком много вещей, им может захотеться использовать некоторые фрагменты байткода повторно вместо того чтобы заниматься копипастингом. Т.е. вам могут понадобиться подобия вызываемых процедур. В простейшей форме они представляют собой всего лишь переход. Единственная разница заключается в том, что виртуальная машина поддерживает второй возвращаемый стек. Когда она выполняет инструкцию «вызова (call)», она помещает индекс текущей инструкции в возвращаемый стек и затем делает переход к вызванному байткоду. Когда она доходит до «возврата (return)», виртуальная машина берет из возвращаемого стека индекс и переходит по нему назад.
В каком виде могут быть представлены значения?
Наша простенькая виртуальная машина работает только с одним типом данных — целыми. Это все сильно упрощает, потому что наш стек — это просто стек целых ( int ) чисел. В более совершенной виртуальной машине поддерживаются разные типы данных: строки, объекты, списки и т.д. И конечно нам нужно определиться с тем, как мы будем их хранить.
- Единый тип данных:
- Это просто. Вам не нужно беспокоиться о тегах, преобразованиях и проверках типа.
- Вы не можете работать с разными типами данных. Очевидный недостаток. Попытка хранить разные типы данных в едином представлении — например чисел в виде строк — не лучшее решение.
enum ValueType < TYPE_INT, TYPE_DOUBLE, TYPE_STRING >; struct Value < ValueType type; union < int intValue; double doubleValue; char* stringValue; >; >;
- Значения знают свой тип. Преимуществом этого решения является то, что мы можем проверить тип значения во время выполнения. Это важно для динамической диспетчеризации и проверки того, что вы не пытаетесь выполнить операцию над типом, который ее не поддерживает.
- Памяти требуется больше. Каждому значению приходится хранить вместе с собой несколько дополнительных бит для обозначения типа. А на таком низком уровне, как виртуальная машина даже несколько лишних бит значат очень много.
Еще именно таким образом хранят данные нетипизированные языки программирования, такие как ассемблер или Forth . Эти языки возлагают ответственность за правильное использование типов на пользователя. Подход явно не для слабонервных!
- Этот вариант компактен. У вас есть возможность забронировать больше битов для хранения самого значения.
- Это быстро. Отсутствие тегов означает, что вам не придется тратить такты процессора на их проверку во время выполнения. Именно поэтому языки со статическим типизированием настолько быстрее языков с динамическими типами.
- Это небезопасно. А вот и расплата. Плохой байткод, перепутавший указатель с целым значением или наоборот может привести к падению вашей игры.
Если ваш байткод скомпилирован из языка со статической типизацией, вы можете подумать, что теперь вы в безопасности, потому что компилятор не сгенерирует некорректный байткод. Это может быть и правдой, но помните, что пользователь злоумышленник может сгенерировать зловредный код и без компилятора. Вот поэтому например в Java Virtual Machine перед выполнением программы запускается верификация байткода.
class Value < public: virtual ~Value() <> virtual ValueType type() = 0; virtual int asInt() < // Можно вызывать только для целых. assert(false); return 0; > // Другие методы конвертации. >;
Далее мы сделаем конкретные классы для разных типов данных, наподобие:
class IntValue : public Value < public: IntValue(int value) : value_(value) <> virtual ValueType type() < return TYPE_INT; > virtual int asInt() < return value_; > private: int value_; >;
- Неограниченные возможности. Вы всегда можете определить новый класс за пределами виртуальной машины. Главное, чтобы он реализовывал базовый интерфейс.
- Объектно-ориентированность. Если вы придерживаетесь принципов ООП , такой подход реализует диспетчеризацию типов «правильным» образом, т.е. определяет специфичное для типа поведение, а не занимается чем-то типа переключения поведения в зависимости от тега.
- Многословность. Вам нужно определить отдельный класс с полным набором возможностей для каждого типа данных. Обратите внимание, что в предыдущих примерах мы продемонстрировали полное определение всех типов значений. А здесь ограничились только одним!
- Неэффективность. Чтобы воспользоваться свойствами полиморфизма, нам нужно выполнить переход по указателю. А это значит, что даже такое простейшее значение как булевское значение или число обернуты в объект, выделенный в куче. Каждый раз при получении значения нужно будет выполнять вызов виртуального метода. В таком ответственном коде как ядро виртуальной машины, даже самое незначительное снижение производительности может быть критичным. На самом деле такое решение страдает от тех же проблем, из-за которых мы отказались от шаблона Интерпретатор с тем лишь отличием, что проблема у нас теперь в значении, а не коде.
Вот что я рекомендую: Если вы способны ограничиться единственным типом данных — так и сделайте. В противном случае используйте объединение с тегом. Именно так и работает большинство интерпретаторов в мире.
Как генерируется байткод?
Самый главный вопрос я оставил напоследок. Я показал вам как применять и как интерпретировать байткод, но создавать что-то, с помощью чего можно производить байткод придется вам. Обычно для этого пишется компилятор, но это не единственное решение.
- Если вы определяете язык на текстовой основе:
- Вам нужно определить синтаксис. Сложность этой задачи недооценивают как новички, так и бывалые архитекторы языков. Определить словарь, удовлетворяющий парсер — легко. Определить удовлетворяющий пользователя — крайне сложно. Дизайн синтаксиса — это дизайн пользовательского интерфейса и этот процесс не становится легче от того, что интерфейс сводится к строкам или символам.
- Вам придется реализовать парсер. Несмотря на репутацию эта часть довольно простая. Вы можете использовать генератор наподобие ANTLR или Bison или по моему примеру самостоятельно написать рекурсивный спуск.
- Вам придется обрабатывать синтаксические ошибки. Это одна из самых важных и сложных частей процесса. Когда пользователи допускают синтаксические и семантические ошибки, а они будут, это ваша задача наставить их на путь истинный. Выдавать полезную обратную связь не так уж и просто, если известно только то, что парсер наткнулся на какую-то ошибку пунктуации.
- Этот способ практически недоступен для технически неподкованных пользователей. Мы программисты любим текстовые файлы. Вместе с мощными инструментами командной строки мы можем думать о них как о компьютерных LEGO блоках: простые, но легко сочетаемые миллионом способов. Большинство непрограммистов так текстовые файлы не воспринимают. Для них это подобно заполнению налоговой декларации для роботизированного аудитора, который ругается на них даже если они забыли поставить единственную точку с запятой.
Если конечно забыть про символы перевода строки. И кодировки.
Смотрите также
- Этот шаблон очень близок шаблону Интерпретатор (Interpreter) GoF . Они оба позволяют вам выражать сложное поведение с помощью данных. В жизни часто происходит так, что вы начинаете использовать оба шаблона. Инструмент, который вы используете для генерации байткода будет содержать внутри дерево объектов, представляющее код. А это именно то, чем занимается шаблон Интерпретатор. Чтобы превратить это в байткод, вы рекурсивно обходите дерево, также как интерпретатор в шаблоне Интерпретатор. Единственное различие в том, что вместо мгновенного выполнения примитивных фрагментов поведения, вы выводите их в виде байткода для выполнения позже.
- Язык программирования Lua — это самый известный скриптовый язык из используемых в играх. Внутри он реализован как крайне компактная виртуальная байткод машина на основе регистров.
- Kismet — это графический скриптовый инстурмент, встроенный в UnrealEd — редактор движка Unreal engine .
- Мой собственный скриптовый язык Wren — это простой интерпретатор байткода на основе стека.
results matching » «
No results matching » «