Pull to refresh

.NET/Mono в Java? Легко!

Reading time 11 min
Views 20K
Здравствуйте. Хочу представить свой проект – компилятор .NET/Mono в Java. Целью проекта является создание компилятора, и набора стандартных библиотек позволяющих переносить написанные приложения и библиотеки на платформу Java, версии 1.6 и выше. Из аналогичных проектов мне известен лишь проект dot42. Но он заточен под Android и имеет собственную стандартную библиотеку не совсем совместимую с .NET/Mono.

Пока есть только альфа версия, и поэтому для реального использования компилятор пока не годится, однако уже частично работоспособен, генерирует валидный код Java и поддерживает часть стандарта ECMA-335.

Исходные коды на github.com: https://github.com/zebraxxl/CIL2Java


Компилятор представляет собой консольное приложение, которое при запуске без параметров выводит справку. Так что с использованием проблем возникнуть не должно.

Ещё хочу отметить, что при работе компилятор исходит из предположения, что весь код, поданный ему на вход, является валидным.

Что не поддерживается


Сразу хочу оговорить, что на данный момент не поддерживается:
  • Неуправляемые указатели
  • Математика над указателями (как управляемыми, так и неуправляемыми)
  • P/Invoke
  • Не векторные массивы (массивы с нижней границей не равной 0)
  • Оператор switch на типе long
  • Опкод calli
  • Фильтры исключений

Последние три пункта планируется реализовать в ближайшее время, на счёт остальных – это долгосрочные планы, если их вообще реально будет реализовать.

Как это работает


Компиляция разделена на три больших этапа. Первым этапом компилятор загружает и преобразует во внутреннее представление все используемые типы. Вторым производит подготовку к третьему этапу. Третьим этапом преобразует метаинформацию и компилирует код.

Преобразование типов во внутреннее представление происходит на лету во время их загрузки. То есть берётся тип, преобразуется во внутреннее представление, затем добавляется в список компиляции. Затем из оригинального типа берутся все поля и методы и так же преобразуются во внутреннее представление. Таким образом, в список компиляции добавляются все типы, их поля и методы из явно указанных в качестве входных, сборок.

Но так же, во время преобразования любого типа, поля или метода (далее члена) так же подгружаются, преобразуются во внутреннее представление и добавляются в список компиляции, все члены от которых исходный член зависит. При этом добавляются только действительно используемые члены. Таким образом, после первого этапа в списке компиляции будут лишь те типы, которые есть в исходных сборках, плюс те типы и их члены, которые необходимы. Благодаря этому на выходе мы получаем откомпилированными исходные сборки и необходимые куски остальных. Для примера возьмём код:
using System;

namespace TestConsole
{
    public class Program
    {
        public static void  Main(string[] args)
        {
            Console.WriteLine("Hello world");
        }

        // Точка входа для Java
        public static void main(string[] args)
        {
            Main(args);
        }
    }
}


После первого этапа, список компиляции будет выглядеть вот так:
0:  type Foo.Program
    methods:
        Main
        main
1:  type System.Console
    methods:
        WriteLine
2:  type System.String


Здесь так же необходимо отметить о механизме подмены сборок. При обнаружении ссылки на тип, который находится во внешней сборке (не указанной в качестве входной), эта сборка будет автоматически подгружена. Однако что делать, если подгружаемая сборка имеет реализацию не совместимую с Java? Например, стандартный mscorlib? Для этого и нужен механизм подмены сборок. По умолчанию, на данный момент, так подменяется mscorlib на специальную реализацию, которая использует для работы механизмы Java. Можно так же указать и другие сборки, для подмены используя ключ компиляции –r. Вкратце работает это следующим образом: когда Mono.Cecil начинает искать откуда загрузить сборку, он обращается с этим вопросом к AssemblyResolver, который ему был передан в качестве параметра при чтении изначальной сборки. AssemblyResolver компилятора сначала ищет сборку с таким именем в ранее загруженных, если там не находит, то смотрит нет ли её в списках на подмену. Если есть, то загружает и возвращает сборку, указанную в списке на подмену. Если же и в списках на подмену её нет, то загружается стандартная сборка, стандартными средствами.

Перед вторым этапом компиляции происходит этап предкомпиляции, в котором компилятор производит дополнительную обработку типов для подготовки их к непосредственной компиляции. К примеру именно на этом этапе добавляются методы которые явно нигде не вызываются, но являются перегруженными виртуальными методами явно используемых методов.

И собственно третий этап — самый основной. Преобразование метаинформации процесс достаточно простой и понятный. Единственное что хотелось бы отметить – это то, что все типы объявляются в результате с публичным доступом из-за несовместимости уровней видимости Java и CIL. Глобальные типы в Java могут иметь либо публичный доступ, либо доступ только из пакета (пространства имён). А вложенные типы в Java, имеющие например закрытый уровень видимости вообще не могут использоваться за пределами типа, в котором объявлены. Если обратится к любому члену такого типа из внешнего класса будет сгенерировано исключение. Так что все типы автоматически становятся открытыми.

А вот компиляция кода более сложный и объёмный процесс, который так же делится на несколько этапов. Первым этапом строится граф кода. Этим занимается библиотека ICSharpCode.Decompiler из состава ILSpy. В целом получается почти готовый для компиляции граф, но все же несколько дополнительных преобразований выполняется. Для примера обратно преобразуются псевдо инструкции CompoundAssignment, которые генерирует ICSharpCode.Decompiler. Ну и после этого собственно происходит компиляция в байт-код Java.

Вот так работает сам компилятор. Теперь я более подробно расскажу о некоторых моментах работы и как реализована поддержка тех или иных вещей.

Дженерики


С точки зрения JVM – дженериков не существует. Дженерики в Java – это лишь расширение компилятора и дженерик с точки зрения JVM – это обычный объект типа java.lang.Object. Дженерики в CLI являются компилируемыми во время исполнения. Это значит, что когда компилятор встречает дженерик, он подставляет вместо него реальный тип и, по сути, создаёт новый тип или метод на основе исходного. CIL2Java действует так же, пропуская методы и типы имеющие джерик параметры и создаёт их только когда встречает ссылку с указанием какие на какие типы заменять эти параметры.

Значимые типы


Это наверно один из главных доводов почему .NET/Mono лучше Java в спорах, что же лучше. Да, в Javа поддержки таких типов нет. Поэтому все значимые типы компилируются как указательные. Но что бы не было проблем из-за разницы в поведении значимых и указательных типов, поведение значимых типов эмулируется. Во первых генерируется конструктор без параметров, и добавляются три внутренних метода:
  • c2j__$__ZeroFill() – заполняет нулями содержимое типа
  • c2j__$__CopyTo(ValueType) – копирует содержимое исходного типа в указанный
  • c2j__$__GetCopy() – создаёт новый экземпляр типа, и копирует в него данные из исходного


Используя эти три метода поведение значимых типов полностью эмулируется. Для примера код «Foo(valType);» будет преобразован в «Foo(valType.c2j__$__GetCopy());» и в метод Foo будет передана копия valType.

Так же для корректной работы все значимые типы автоматически инициализируются конструктором по умолчанию в конструкторах и в самом начале методов (прологе).

Таким образом, главное преимущество таких типов – при правильном использовании они увеличивают скорость работы приложения, не просто теряется, а наоборот их использование будет замедлять работу приложения.

Перечисления


В .NET/Mono перечисления по своей сути являются значимыми типами, но с дополнительными ограничениями. У них не может быть никаких методов, всего одно не статическое поле примитивного типа (int, short и т.д.) имеющее имя «value__» и статические поля имеющие тип самого перечисления.

При компиляции вместо типа перечисления, подставляется его базовый тип. То есть метод «void Foo(EnumType val);» после компиляции станет «void Foo(int val);».

Упаковка


Упаковка значимых типов делится на три категории: упаковка примитивных типов, упаковка значимых типов и упаковка перечислений.

Упаковка примитивных типов реализована двумя способами: упаковка в типы CIL или упаковка в типы Java. В первом случае в качестве типов для упаковки используются стандартные для CIL типы из пространства имён System (System.Int32, System.Single и т.д.). Во втором – стандартные типы для Java (java.lang.Integer, java.lang.Float и т.д.)

В случае упаковки в типы CIL мы сохраняем информацию о беззнаковых типах и код вида «uintType.ToString()» будет иметь правильный результат. Однако при передаче таких параметров в Java, в методы где требуется передать упакованный примитивный тип (например java.lang.reflect.Method.invoke) компилятору будет необходимо генерировать код переупаковки (правда пока этой функции в компиляторе нет), и, таким образом, будет падать производительность.

В случае упаковки в типы Java все соответственно наоборот. Код «uintType.ToString()» даст неправильный результат, если значение uintType будет больше 2 147 483 647, но не будет лишних переупаковок из CIL в Java и обратно. Какой метод применять – решать вам. За это отвечает параметр компиляции – box. По умолчанию происходит упаковка в типы CIL.

С упаковкой значимых типов все проще. Берём копию типа и просто передаём её. Он и так по факту, после компиляции, становится указательным типом.

А вот перечисления упаковываются в их настоящий тип. То есть, если имеется перечисление типа EnumType, имеющее базовый тип int, то, как было сказано выше, при компиляции вместо EnumType будет подставляться тип int. А вот в случае упаковки, будет создан объект именно типа EnumType, а в его поле value__ будет положено значение этого перечисления. Таким образом, будет сохранена информация о типе.

Указатели


Как уже было сказано, компилятор не поддерживает небезопасные указатели. А вот передача по ссылке вполне успешно работает. Если в метод передаётся значение по ссылке, то типом этого параметра станет тип CIL2Java.VES.ByRef[type], где [type] – это тип на который создаётся ссылка (возможные значения: Byte, Short, Int, Long, Float, Double, Bool, Char, Ref). Отдельные типы для примитивных типов необходимы для того, что бы не упаковывать/распаковывать их при каждом обращении. Сам тип ссылки является абстрактным классом с двумя абстрактными методами: get_Value и set_Value для получения и установки значения по ссылке соответственно. Вот так это выглядит:
public abstract class ByRef[type]
{
    public abstract [type] get_Value();
    public abstract void set_Value([type] newValue);
}


При создании ссылки на значение, создаётся объект который реализует соответственный абстрактный класс. И реализует в зависимости от того, где хранится значение на которое мы создаём ссылку:

LocalByRef[type] – ссылка на локальную переменную или параметр метода. Просто хранит в себе значение до выхода из вызываемого места, после чего происходит восстановление значения переменной или параметра.
Возьмём вот такой код:
public class Program
{
    public static void Foo(ref int refValue)
    {
        refValue = 10;
    }

    public static void  Main(string[] args)
    {
        int localVar = 0;
        Foo(ref localVar);
    }

    // Точка входа для Java
    public static void main(string[] args)
    {
        Main(args);
    }
}


После компиляции код будет выглядеть вот так:
public class LocalByRefInt : ByRefInt
{
    private int value;

    public LocalByRefInt(int initialValue) { value = initialValue; }

    public override int get_Value() { return value; }
    public override void set_Value(int newValue) { value = newValue; }
}

public class Program
{
    public static void Foo(ByRefInt refValue)
    {
        refValue.set_Value(10);
    }

    public static void  Main(string[] args)
    {
        int localVar = 0;
        LocalByRefInt tmpByRef = new LocalByRefInt(localVar);
        Foo(tmpByRef);
        localVar = tmpByRef.get_Value();
    }

    // Точка входа для Java
    public static void main(string[] args)
    {
        Main(args);
    }
}


FieldByRef[type] – ссылка на поле объекта. Реализуется силами рефлексии. Вот так выглядит этот тип после компиляции:
public class FieldByRef[type] : ByRef[type]
{
    private object target;
    private java.lang.reflect.Field field;
    private [type] value;

    public FieldByRefInt(object target, Field targetField)
    {
        this.target = target;
        this.field = targetField;
        paramField.setAccessible(true);
        this.value = targetField.get[type](target);
    }

    public [type] get_Value()
    {
        return this.value;
    }

    public void set_Value([type] newValue)
    {
        this.field.set[type](this.target, newValue);
        this.value = newValue;
    }
}


ArrayByRef[type] – ссылка на элемент массива. Тут всё просто – сохраняем сам массив (который является указательным типом) и индекс в этом массиве. Вот так это выглядит после компиляции:
public class ArrayByRef[type] : ByRef[type]
{
    private [type][] array;
    private int index;
    private int value;

    public ArrayByRefInt([type][] paramArray, int index)
    {
        this.array = paramArray;
        this.index = index;
        this.value = paramArray[index];
    }

    public int get_Value()
    {
        return this.value;
    }

    public void set_Value(int newValue)
    {
        this.array[this.index] = newValue;
        this.value = newValue;
    }
}


Указатели на методы и делегаты


Это то, чего мне сильнее всего не хватает в Java. Одним из способов реализации указателей на методы является рефлексия. Но мне этот вариант не понравился тем, что требует упаковки параметров, что снижает производительность. Таким образом был использован второй способ.

В дальнейшем описании я буду использовать вот такой пример:
using System;

namespace TestConsole
{
    public delegate void Deleg(int f);

    public class Program
    {
        public void Foo(int f)
        {
            Console.WriteLine(f);
        }

        public static void  Main(string[] args)
        {
            Program p = new Program();

            Deleg d = new Deleg(p.Foo);
            d(10);
        }

        // Точка входа для Java
        public static void main(string[] args)
        {
            Main(args);
        }
    }
}


Способ заключается в том, что если мы встречаем инструкцию ldftn или ldvirtftn то сначала генерируется интерфейс в пространстве имён CIL2Java.VES.MethodPointers с именем, зависящим от сигнатуры метода и с единственным методом invoke, имеющим почти такую же сигнатуру что и метод, на который мы получаем указатель, добавив первым параметром ссылку на объект в котором необходимо вызвать метод. В нашем примере такой интерфейс будет выглядеть вот так:

public interface __void_int
{
    void invoke(object target, int param);
}


Затем, каждая инструкция ldftn или ldvirtftn генерирует вложенный тип реализующий интерфейс указателя на метод. Метод invoke просто вызывает метод, на который инструкция получает указатель. В приведённом примере это выглядит так:

public class C2J_anon_0 : __void_int
{
    public void invoke(object target, int paramInt)
    {
        ((Program)target).Foo(paramInt);
    }
}


И уже в конструктор делегата, в качестве указателя на метод, передаётся экземпляр данного класса.

Сам делегат после компиляции принимает такой вид:

public sealed class Deleg : MulticastDelegate
{
    public Deleg(object target, __void_int method_pointer)
        : super(paramObject, method_pointer)
    {
    }

    public sealed void Invoke(int paramInt)
    {
        ((__void_int)this.method).invoke(this.target, paramInt);
        if (this.next != null)
            ((Deleg)this.next).Invoke(paramInt);
    }
}


Таково поведение компилятора по умолчанию. Как вы можете заметить, сигнатура конструктора делегата изменена – последний параметр имеет тип интерфейса указателя метода, а не native int как это необходимо по стандарту. Сделано это опять же для оптимизации. Однако вы можете указать компилятору что необходимо компилировать указатели на метод согласно стандарту используя параметр "-method_pointers standart". В таком случае создание делегата в нашем примере принимает вид:
Deleg d = new Deleg(p, Global.AddMethodPointer("TestConsole.Program$C2J_anon_0"));


А сам делегат становится вот таким:
public sealed class Deleg : MulticastDelegate
{
    public Deleg(object target, int paramInt)
        : base(target, Integer.valueOf(paramInt));
    {
    }

    public sealed void Invoke(int paramInt)
    {
        ((__void_int)Global.GetMethodPointer(((Integer)this.method).intValue())).invoke(this.target, paramInt);
        if (this.next != null)
            ((Deleg)this.next).Invoke(paramInt);
    }
}


Как вы можете видеть, в таком случае, указатель на метод имеет тип int, но на самом деле, это всего лишь индекс в глобальном списке указателей на методы. Таким способом, мы соблюдаем стандарт, но теряем в производительности.

yield return/break


Здесь если честно рассказывать нечего. Просто работает.

Async/await


Здесь тоже особо рассказывать нечего. Код использующий async/await компилируется, но не работает. Не работает потому что нет реализации необходимых для работы типов (System.Threading.Tasks.Task, System.Runtime.CompilerServices.AsyncTaskMethodBuilder и так далее)

Беззнаковые числа


Поддержка беззнаковых чисел в компиляторе имеется, но включается отдельно параметром "-unsigned". В реализации очень помогла статья http://habrahabr.ru/post/225901/ за авторством elw00d. В целом в этой статье всё описано и все операции с беззнаковыми числами были сделаны по этой статье.

Исключения


В целом исключения в Java и в CIL очень похожи. Пока правда не поддерживаются фильтры исключений (их не поддерживает ICSharpCode.Decompiler).

Дополнительно, был добавлен механизм связки типов исключений Java и CIL. К примеру в CIL имеется исключение System.ArithmeticException. В Java имеется свой тип java.lang.ArithmeticException. Как сделать так, что бы перехватывая System.ArithmeticException перехватывался так же и java.lang.ArithmeticException? Для этого был введёт атрибут JavaExceptionMapAttribute который указывает компилятору аналогичное исключение в Java. И когда компилятор встречает перехват System.ArithmeticException, он так же добавляет перехват и аналогичного Java исключения. Единственное что добавляется условие что в System.ArithmeticException должен быть введён дополнительный конструктор, принимающий только один параметр типа java.lang.ArithmeticException для того, что бы перехватчику был передан экземпляр исключения одного типа.

Отладка


Компилятор поддерживает генерацию отладочной информации (если она есть в исходных сборках) при указании ключа компиляции "-debug". Вот пример того, как тестовое приложение отлаживается в Eclipse:


Подмена типов


Данный механизм был создан для того, что бы типы, имеющие аналогичные в Java, можно было при компиляции превращать в эти самые аналоги. Пример такого типа – System.String. В реализации mscorlib этот тип помечен атрибутом TypeMapAttribute, а при компиляции превращается в java.lang.String. Так же возможна и подмена отдельных методов. Для этого их необходимо помечать атрибутом MethodMapAttribute.

Заключение


Вот в целом и всё. Это лишь альфа версия проекта, и пока стабильность работы оставляет желать лучшего. Так что дальнейший вектор работы – улучшение стабильности работы и реализация стандартной библиотеки. Спасибо что дочитали до конца.
Tags:
Hubs:
+68
Comments 36
Comments Comments 36

Articles