post title image
💠

Выразительный F#. Часть 0

devF#

Интересно, что в англоязычном информационном пространстве в связи с программированием часто употребляется слово "кодинг". Мне кажется это две стороны одной медали, они дополняют друг друга.

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

Кодинг (кодирование) - это то, как мы это делаем. Мы кодируем (преобразуем) информацию о действиях и их последовательности с естественного языка, привычного человеку, на формальный язык, понятный компьютеру. Почему мы не можем объясняться с компьютером на своем языке? Потому что наш язык слишком сложный, у него есть очень поэтично звучащее свойство: неограниченная семантическая мощность. Оно означает, что используя свой язык мы можем выразить все, что способны наблюдать или вообразить. От языков программирования такого не требуется, достаточно возможности реализовать любую вычислимую функцию (это называется полнота по Тьюрингу). Еще один нюанс - субъективность в восприятии естественных языков. Как минимум, в зависимости от контекста одни и те же слова можно понимать по-разному. В формальном языке одинаковые сочетания знаков всегда имеют одинаковый смысл.

Однако последнее время наши формальные языки программирования как будто потянулись в сторону естественного (хотя бы по форме). Это конечно не означает, что компьютер начал понимать естественный язык, просто теперь мы прогоняем наш текст через переводчик (компилятор) либо используем синхронный перевод (интерпретатор).

Для F# (на самом деле для всего .NET) есть интересный инструмент - sharplib.io, который позволяет увидеть этот процесс.

Выберем в левой панели F# и напишем самое простое выражение:

let x = 5

Обзор языка F# говорит, что это называется связыванием (binding). Мы говорим компьютеру (и программисту, который будет потом читать этот код) давай условимся, что далее под x мы имеем в виду 5. Очень близко к естественному языку, не правда ли?

Если теперь в правой панели выбрать C# мы увидим эквивалентный код на этом языке.

using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.FSharp.Core;

[assembly: FSharpInterfaceDataVersion(2, 0, 0)]
[assembly: AssemblyVersion("0.0.0.0")]
[CompilationMapping(SourceConstructFlags.Module)]
public static class @_{
    public static int x    {
        [CompilerGenerated]
        [DebuggerNonUserCode]
        get
        {
            return 5;        }
    }
}
namespace <StartupCode$_>
{
    internal static class $_
    {
    }
}

Он гораздо многословнее, возникает множество сложных конструкций. Появляется новый синтаксис - фигурные скобки. Поскольку это трансляция с другого языка, а не изначально написанный на C# код, через директиву using подключаются системные библиотеки, имеющие отношение к компиляции, и классу назначаются специальные атрибуты. Мы можем пренебречь сейчас этими деталями, все самое важное сосредоточено в выделенных строках. C# не знает ни каких "давай", он объясняется с помощью классов - условно, выделенных фрагментов памяти компьютера, которые представляют собой связанный набор данных. В нашем случае создается класс и у него есть свойство x, которое имеет целочисленное значение 5. Вот мы уже начинаем погружаться в тему выделения памяти и компьютерные потроха.

Это не значит, что программа на F# совершает меньше действий. Обе эти программы эквивалентны при переводе на машинный код (еще он называется языком ассемблера). Просто F# избавляет нас от этих подробностей как не очень значимых в рамках нашей задачи.

Теперь переключим правую панель на IL. На самом деле это сокращенное Common Intermediate Language (или CIL) - «высокоуровневый ассемблер» виртуальной машины .NET. Таким образом для платформы .NET перевод по сути выполняется дважды. Сначала компилятором в CIL, потом JIT-компилятором в машинный код при исполнении программы.

.assembly _
{
    .custom instance void [FSharp.Core]Microsoft.FSharp.Core.FSharpInterfaceDataVersionAttribute::.ctor(int32, int32, int32) = (
        01 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00
    )
    .hash algorithm 0x00008004 // SHA1
    .ver 0:0:0:0
}

.class private auto ansi '<Module>'
    extends [System.Runtime]System.Object
{
} // end of class <Module>

.class public auto ansi abstract sealed _
    extends [System.Runtime]System.Object
{
    .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationMappingAttribute::.ctor(valuetype [FSharp.Core]Microsoft.FSharp.Core.SourceConstructFlags) = (
        01 00 07 00 00 00 00 00
    )
    // Methods
    .method public specialname static
        int32 get_x () cil managed
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        .custom instance void [System.Runtime]System.Diagnostics.DebuggerNonUserCodeAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2050
        // Code size 2 (0x2)
        .maxstack 8

        IL_0000: ldc.i4.5
        IL_0001: ret
    } // end of method _::get_x

    // Properties
    .property int32 x()
    {
        .get int32 _::get_x()
    }

} // end of class _

.class private auto ansi abstract sealed '<StartupCode$_>.$_'
    extends [System.Runtime]System.Object
{
} // end of class <StartupCode$_>.$_

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

IL_0000: ldc.i4.5
IL_0001: ret

Первая инструкция это load numeric constant - загрузить константное (неизменяемое) числовое значение, i4 это 32 битное целое число и само значение - 5. Эта инструкция помещает число в стек. Под стеком на данном этапе можно представить область памяти, куда мы можем складывать значения и потом их забирать. Важное свойство - данные, которые попали в стек первыми, извлекаются оттуда последними. ret это выход из подпрограммы. Так же как в примере на C# у нас есть класс обладающий свойством x, но обращение к этому свойству помещает значение 5 в стек.

Давайте погрузимся еще на один уровень и выберем на правой панели JIT Asm - это ассемблерный код скомпилированный тем самым JIT-компилятором. На моем компьютере (AMD Ryzen 5 PRO и 64-разрядная ОС) он выглядит так:

_.get_x()
    L0000: mov eax, 5
    L0005: ret

Это уже работа с регистрами процессора. Регистры – это небольшие (обычно 4 или 8 байт) ячейки памяти, расположенные непосредственно в процессоре. Работа с регистрами выполняется намного быстрее, чем с ячейками оперативной памяти. mov это команда пересылки данных, eax это регистр общего назначения в процессоре. С помощью этой команды мы помещаем в наш регистр значение 5. Инструкция ret уже нам знакома по примеру с IL. Если не знать команды ассемблера и про регистры процессора - эти инструкции невозможно прочитать, хотя в имени mov смутно угадывается слово move. На самом деле язык ассемблера – это символическое представление машинного языка. Поэтому компьютер, конечно, никакие mov не исполняет, и команда и адрес регистра и само значение выглядят как набор двоичных чисел.

Обратите внимание насколько разные с нашей точки зрения примеры на F# и ассемблере и насколько они близки, если сравнивать с полным инфраструктурных деталей монстром на IL. Круг замкнулся, мы просто записываем то, что нужно сделать - поместить число 5 туда, где ему нужно по нашему мнению быть. Разница в том, что код на ассемблере работает с конкретной архитектурой процессора, а код на F# обладает максимально доступной нам на текущий момент степенью абстрагирования.