Итак, эта история началась с совпадения трёх факторов. Я:

  1. в основном писал на C#;
  2. лишь примерно представлял, как он устроен и работает;
  3. заинтересовался ассемблером.

Эта, на первый взгляд, невинная смесь породила странную идею: а можно ли как-то совместить эти языки? Добавить в C# возможность делать ассемблерные вставки, примерно как в C++.

Если вам интересно, к каким последствиям это привело, — добро пожаловать под кат.



Первые сложности


Уже в тот момент я понимал, что очень вряд ли есть стандартные инструменты для вызова ассемблерного кода из кода C# — это слишком сильно противоречит одной из важных концепций языка: безопасности работы с памятью. После поверхностного изучения вопроса (которое, кроме всего прочего, подтвердило изначальную догадку — «из коробки» такая возможность отсутствует) стало понятно, что кроме проблемы идейного характера есть и проблема чисто техническая: C#, как известно, компилируется в промежуточный байт-код, который в дальнейшем интерпретируется виртуальной машиной CLR. И как раз здесь перед нами в полный рост встаёт та самая проблема: с одной стороны, компилятор (здесь и дальше я буду подразумевать Roslyn от Microsoft, поскольку он де-факто является стандартом в области C#-компиляторов), очевидно, не умеет распознавать и транслировать ассемблерные команды из текстового вида в какое-либо двоичное представление, а значит, в качестве вставки мы должны использовать непосредственно машинные команды в их бинарном виде, а с другой — виртуальная машина имеет свой собственный байт-код и не может распознать и выполнить тот набор команд, который предложим ей мы.

Теоретическое решение этой проблемы очевидно — нужно сделать так, чтобы двоичный код вставки выполнялся процессором, минуя интерпретацию виртуальной машиной. Самое простое, что приходит в голову, — хранить двоичный код в виде массива байт, на который в нужный момент каким-то образом будет передано управление. Отсюда вырисовывается первая задача: нужно придумать способ передачи управления тому, что содержится в произвольной области памяти.

Первый прототип: «вызов» массива


Задача эта — пожалуй, самое серьёзное препятствие на пути к вставкам. Средствами языка несложно получить указатель на наш массив, но в мире C# указатели существуют только на данные и превратить его в указатель на, скажем, функцию, чтобы её потом вызвать, невозможно (ну или, по крайней мере, мне не удалось придумать, как это сделать).

К счастью (или к сожалению), ничто не ново под луной и быстрый поиск в Яндексе по словам «C#» и «ассемблерные вставки» привёл меня к статье в декабрьском выпуске журнала "][акер" за 2007 год. Честно скопипастив оттуда функцию и подогнав её к своим нуждам, получил

[DllImport("kernel32.dll")]
extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect);

public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code)
{
    int i = 0;
    int* p = &i;
    p += 0x14 / 4 + 1;
    i = *p;
    fixed (byte* b = code)
    {
        *p = (int)b;
        uint prev;
        VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev);
    }
    return (void*)i;
}

Основная идея этого кода — подменить в стеке адрес возврата из функции InvokeAsm() на адрес массива байт, которому требуется передать управление. Тогда после выхода из функции вместо продолжения выполнения программы начнётся выполнение нашего двоичного кода.

Разберёмся с магией, творящейся в InvokeAsm(), поподробнее. Сначала мы объявляем локальную переменную, которая, разумеется, оказывается в стеке, потом получаем её адрес (получая тем самым адрес верхушки стека). Дальше добавляем к нему некую магическую константу, полученную путём кропотливого подсчёта в отладчике смещения адреса возврата относительно верхушки стека, сохраняем адрес возврата и записываем вместо него адрес нашего массива байт. Сакральный смысл сохранения адреса возврата очевиден — нам же нужно продолжить выполнение программы после нашей вставки, а значит, надо знать, куда после неё передавать управление. Дальше идёт вызов WinAPI-функции из библиотеки kernel32.dll — VirtualProtect(). Он нужен для того, чтобы изменить атрибуты страницы памяти, на которой находится код вставки. Он, разумеется, при компиляции программы оказывается в секции данных, а соответствующая страница памяти имеет доступ для чтения и записи. Нам же нужно добавить ещё и разрешение на исполнение её содержимого. Наконец, мы возвращаем сохранённый настоящий адрес возврата. Разумеется, в код, вызвавший InvokeAsm(), этот адрес не вернётся, т.к. выполнение сразу же после return (void*)i; «провалится» во вставку. Однако, используемые виртуальной машиной соглашения о вызове (stdcall с отключенной оптимизацией и fastcall со включенной) подразумевают возврат значения через регистр EAX, т.е. для возврата из вставки нам понадобится выполнить две инструкции: push eax (код 0x50) и ret (код 0xC3).

Уточнение
В дальнейшем речь пойдёт об архитектуре x86 (или, вернее, IA-32) — банально из-за того, что на тот момент именно с ней я был хоть как-то знаком, в отличие от, скажем, x86-64. Однако описанный выше способ передачи управления должен работать и для 64-битного кода.

Наконец, стоит обратить внимание на два неиспользуемых аргумента: void* firstAsmArg и void* secondAsmArg. Они нужны для передачи произвольных пользовательских данных в ассемблерную вставку. Расположены эти аргументы будут либо в известном месте стека (stdcall), либо в, опять же, известных регистрах (fastcall).

Немного об оптимизации
Поскольку с точки зрения компилятора в коде творится не пойми что, он может ненароком выкинуть какой-то принципиально важный вызов/что-то заинлайнить/не сохранить какой-то «неиспользуемый» аргумент/ещё как-то помешать осуществлению нашего плана. Частично это решается атрибутом [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)], однако даже такие меры предосторожности не дают нужного эффекта: например, ключевая для всей функции локальная переменная i внезапно оказывается не стековой, а регистровой, что, очевидно, всё портит. Поэтому чтобы полностью исключить вероятность того, что что-то пойдёт не так, следует собирать библиотеку с отключенной оптимизацией (либо отключить её в свойствах проекта, либо использовать конфигурацию Debug). Следовательно, использоваться будет stdcall, поэтому в дальнейшем я буду исходить именно из этого соглашения о вызовах.

Улучшения


«Безопасное» лучше небезопасного


Разумеется, ни о какой безопасности (в том смысле, с котором это слово используется в C#) здесь не может быть и речи. Однако вышеописанный метод InvokeAsm() оперирует указателями, а значит, вызываться может только из блока, помеченного ключевым словом unsafe, что не всегда удобно — как минимум, это требует компиляции с ключом /unsafe (ну или соответствующей галочки в свойствах проекта в VS). Поэтому логичным кажется предоставить оболочку, оперирующую хотя бы IntPtr (на худой конец), а в идеале — и вовсе позволяющую пользователю указывать передаваемые и возвращаемые типы. Что ж, это звучит как generic, пишем generic, о чём тут ещё, спрашивается, говорить? На самом деле — есть о чём.

Самое очевидное: а как получить указатель на аргумент, тип которого неизвестен? Конструкции типа T* ptr = &arg в C# не допускаются, и, в общем-то, несложно понять причину: пользователь вполне может в качестве параметра типа использовать один из управляемх типов, указатель на который получить невозможно. Решением могло бы стать ограничение параметра типа unmanaged, но оно, во-первых, появилось только в C# 7.3, а во-вторых — не позволяет передавать в качестве аргументов строки и массивы, хотя оператор fixed их использовать позволяет (указатель при этом получаем на первый символ или элемент массива соответственно). Ну и, к тому же, хотелось бы дать пользователю возможность оперировать в том числе и управляемыми типами — раз уж мы взялись нарушать правила языка, то будем их нарушать до конца!

Получение указателя на управляемый объект и объекта по указателю


И вновь после не особо плодотворных раздумий я начал искать готорвые решения. На этот раз помогла мне статья на Хабре. Если вкратце, один из предлагаемых в ней методов заключается в том, чтобы написать вспомогательную библиотеку, причём не на C#, а непосредственно на IL. Её задача — помещать в стек виртуальной машины объект (фактически — ссылку на объект), передаваемый в качестве аргумента, после чего извлекать из стека что-то другое — например, число или IntPtr. Проделав те же действия в обратной последовательности, можно преобразовать указатель (например, возвращённый из ассемблерной вставки) в объект. Этот способ хорош тем, что всё происходящее понятно и прозрачно. Но есть и минус: я хотел обойтись как можно меньшим количеством файлов, поэтому вместо написания отдельной библиотеки решил встроить IL-код в основную. Единственный найденный мной способ — написать на C# методы-заглушки, собрать проект, дизассемблировать бинарник с помошью ildasm, переписать код методов-заглушек и собрать всё это обратно с помощью ilasm. Это довольно много дополнительных действий, а если учесть, что проделывать их нужно при каждой сборке после внесения в код любых изменений… В общем, довольно быстро мне это надоело, и я начал искать альтернативы.

Как раз в это время мне в руки попала замечательная книга, благодаря которой я узнал для себя много нового — «CLR via C#» Джеффри Рихтера. В ней, где-то в районе двадцатой главы, речь зашла про структуру GCHandle, у которой есть метод Alloc(), принимающий объект и один из элементов перечисления GCHandleType. Так вот, если вызвать этот метод передав ему желаемый объект и GCHandle.Pinned, то можно получить адрес этого объекта в памяти. Более того, до вызова GCHandle.Free() объект фиксируется, т.е. полностью защищается от воздействия сборщика мусора. Однако и тут есть определённые проблемы. Во-первых, GCHandle никоим образом не помогает совершить преобразование «указатель > объект», только «объект > указатель». Что важнее, для использования GCHandleType.Pinned класс или структура объекта, адрес которого мы хотим получить, должны иметь атрибут [StructLayout(LayoutKind.Sequential)], в то время как по умолчанию используется LayoutKind.Auto. Так что такой способ подойдёт лишь для некоторых стандартных типов и для тех пользовательских типов, которые изначально проектировались с учётом этой особенности. Не совсем тот универсальный метод, который мы хотели бы найти, верно?

Что ж, попробуем ещё раз. Теперь обратим внимание на две недокументированные функции, которые, тем не менее, поддерживаются Roslyn-ом: __makeref() и __refvalue(). Первая из них принимает объект и возвращает экземпляр структуры TypedReference, хранящей ссылку на объект и его тип, вторая же извлекает объект из передаваемого экземпляра typedReference. Почему эти функции так для нас важны? Потому что TypedReference — структура! В контексте обсуждения это значит, что мы можем получить указатель на неё, который, по совместительству, будет являться указателем на первое поле этой структуры. А именно в нём хранится та самая интересующая нас ссылка на объект. Тогда для получения указателя на управляемый объект нам нужно прочитать значение по указателю на то, что вернёт __makeref() и сконвертировать это в указатель. Для получения же объекта по указателю необходимо вызвать __makeref() от, условно, пустого объекта требуемого типа, получить указатель на возвращаемый экземпляр TypedReference, записать по нему указатель на объект, после чего вызвать __refvalue(). В итоге получился примерно такой код:

public static Tout ToInstance<Tout>(IntPtr ptr)
{
    Tout temp = default;
    TypedReference tr = __makeref(temp);
    Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr);
    Tout instance = __refvalue(tr, Tout);
    return instance;
}

public static void* ToPointer<T>(ref T obj)
{
    if (typeof(T).IsValueType)
    {
        return *(void**)&tr;
    }
    else
    {
        return **(void***)&tr;
    }
}

Замечание
Возвращаясь к задаче написания безопасной обёртки для InvokeAsm(), следует отметить, что метод получения указателей с помощью __makeref() и __refvalue(), в отличие от использования GCHandle.Alloc(GCHandleType.Pinned), не гарантирует того, что сборщик мусора никуда наш объект не передвинет. Поэтому обёртка должна начинаться с отключения сборщика мусора и заканчиваться восстановлением его функциональности. Решение довольно грубое, но эффективное.

Для тех, кто не помнит опкоды


Итак, мы узнали, как вызывать двоичный код, научились передавать ему в качестве аргументов не только непосредственные значения, но и указатели на что угодно… Осталась лишь одна проблема. Где взять тот самый двоичный код? Можно вооружиться карандашом, блокнотом и таблицей опкодов (например, этой) или взять шестнадцатеричный редактор с поддержкой ассемблера x86 или даже полноценный транслятор, но все эти варианты подразумевают, что пользователю придётся использовать ещё что-то кроме библиотеки. Это — не совсем то, чего мне хотелось, поэтому я решил включить в состав библиотеки свой транслятор, который по устоявшейся традиции был назван SASM (сокращение от Stack Assembler; никакого отношения к IDE не имеет).

Disclaimer
Я не силён в парсинге строк, поэтому код транслятора… ну, неидеален, мягко говоря. Кроме того, не силён я и в регулярных выражениях, поэтому их там нет. И вообще — парсер итеративный.

Рассказывать о процессе создания этого «чуда» я, пожалуй, не буду — нет в этой истории ничего интересного, а вот основные возможности кратко опишу. На данный момент поддерживается большинство инструкций x86. Инструкции математического сопроцессора для работы с числами с плавающей точкой и из расширений (MMX, SSE, AVX) пока не поддерживаются. Есть возможность объявления констант, процедур, локальных стековых переменных, глобальных переменных, память под которые выделяется в процессе трансляции непосредственно в массиве с двоичным кодом (если эти переменные именованы при помощи меток, то их значение можно получить и из C# после выполнения вставки путём вызова методов GetBYTEVariable(), GetWORDVariable(), GetDWORDVariable(), GetAStringVariable() и GetWStringVariable() объекта SASMCode), присутствуют макросы addr и invoke. Одной из важных особенностей является поддержка импорта функций из внешних библиотек с помощью конструкции extern <имя функции> lib <имя библиотеки>.

Отдельного абзаца достоин макрос asmret. В процессе трансляции он разворачивается в 11 инструкций, образующих эпилог. В начало же транслируемого кода по умолчанию добавляется пролог. Их задача — сохранение/восстановление состояния процессора. Кроме того, пролог добавляет четыре константы — $first, $second, $this и $return. В процессе трансляции эти константы заменяются адресами в стеке, по которым находятся, соответственно, первый и второй аргументы, переданные ассемблерной вставке, адрес первой команды вставки и адрес возврата.

Итог


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

Если же всё же попытаться как-то обобщить всё сделанное — то, на мой взгляд, получился интересный и даже, в какой-то степени, небесполезный проект. Например, идентичные алгоритмы сортировки вставками на C# и с помощью ассемблерной вставки по скорости отличаются более чем в два раза (разумеется, в пользу ассемблера). В серьёзных проектах, конечно, получившуюся библиотеку использовать не рекомендуется (возможны, хоть и не слишком вероятны, непредсказуемые побочные эффекты), но для себя — вполне можно.

Комментарии (36)


  1. loginsin
    25.08.2019 16:28

    Делал подобное на vb6 лет 15 назад. Правда там использовал известную для ВэБэшников api функцию CallWindowProc. Впрочем, метод (как тот, так и описанный здесь в статье) прибит гвоздями к x86.


    1. kaleman
      25.08.2019 20:59

      CallWindowProc использовали при царе Горохе (в 90-ые) а последние лет 15 — DispCallFunc


  1. blanabrother
    25.08.2019 16:51

    Чтобы Вы не тратили время на разработку и отладку генератора нативного кода, вот Вам ссылка на проект, в котором реализованы все инструкции x86/x64, даже векторизация.


    1. Midiy Автор
      25.08.2019 19:22

      Спасибо, может пригодиться.


  1. BkmzSpb
    25.08.2019 18:04
    +3

    Для работы с указателями и ref — структурами можно посмотреть в сторону
    System.Runtime.CompilerServices.Unsafe. Класс доступен как в netcore3.0 так и в netstandard2.0 как самостоятельный nuget-пакет.
    Позволяет кастовать (в определенных случаях) ссылки к указателям и обратно, а так же "безопасно" выполнять арифметику над указателями.


    1. Midiy Автор
      25.08.2019 19:19

      Хм, да, он, возможно, заметно облегчил бы мне задачу. Спасибо, заинтересовался! Надо будет глянуть их реализацию.


  1. ormoulu
    25.08.2019 19:09
    +1

    Сударь знает толк…
    Как насчет написать процедуру в байт-кодах и передать управление в нее?


    1. Midiy Автор
      25.08.2019 19:21

      В IL-овских? Мысль. Попробую на досуге что-нибудь придумать. Глядишь, ещё в один пост выльется…


      1. blanabrother
        25.08.2019 20:55

        Кстати. Если Вам достаточно иметь метод с передаваемыми аргументами, то вполне достаточно аллоцировать память через Marshal.AllocHGlobal например, потом запротектить на выполнение (единственный хак вне платформы), затем в этот участок памяти перелить опкоды (тут iced либа в помощь), затем через тот же Marshal получить вполне законный управляемый делегат — Marshal.GetDelegateForFunctionPointer, который можно где-то сохранить и переиспользовать. Все законно, в рамках платформы.


      1. DistortNeo
        25.08.2019 21:15

        Генерация IL-кода на лету доступна "из коробки".
        Google "ilgenerator", например: https://habr.com/ru/company/skbkontur/blog/262711/


        А вот возиться с нативным ассемблером из C# не вижу смысла.


    1. qw1
      25.08.2019 19:43

      Если интересно, можете здесь посмотреть.


  1. maximnik0q
    25.08.2019 21:48

    А почему не использовали Mono?
    Там что то упоминалось про вставки на ассемблере если вы компилируете в наитивный код (не знаю -не слежу, убрали ли эту возможность в связи с объединением кодовой базы с Net ).То есть получается исполняемый файл под конкретную архитектуру, если на размер плевать то можно сделать автономным, не завищищий есть ли у тебя mono -среда,.А если что вызывать из ехешника библиотеки среды исполнения, тогда размер поменьше.


    1. Midiy Автор
      25.08.2019 22:01

      Потому что с Mono знаком исключительно понаслышке и про такую возможность услышал сейчас впервые. К тому же всё это делалось не ради практической пользы, а, скорее, из интереса. Интереса же в использовании готового функционала немного…


  1. mjr27
    25.08.2019 22:26
    +3

    Если уж извращаться, то почему бы не через expressions?
    Типа
    Action a = Asm.Compile((ptr)=>{
    var label1 = Asm.Mov(Asm.Ecx, ptr);
    Asm.Cmp(Asm.Ecx, Asm.Edx);
    Asm.Je(label1);
    })


  1. Siemargl
    25.08.2019 22:45
    -2

    У C# (да и прочих IL-языков) никаких шансов пробиться в топ по производительности.

    Негативных факторов слишком много.

    Насколько я помню, первые попытки отмазаться встроенным ассемблером начались в Дельфях. Хотя сильно не уверен в первенстве.

    Но главный вопрос — а нафига?


    1. Alexx999
      26.08.2019 10:15
      +1

      Вообще возможно собрать что-то на синтаксисе C# что будет компилироваться в идеально прилизанный нативный код.
      У разработчиков Unity в блоге есть занимательный текст (на английском) как они перевели performance critical вещи с C++ на C# и вполне счастливы, суть претензий — в борьбе с компиляторами С++ где векторизация имеет склонность тихо отваливаться.
      Но, конечно, уже это не совсем C#


    1. makarychev13
      26.08.2019 10:15
      +1

      В данной статье человек делает ассемблерные вставки не для того, чтобы повысить производительность


    1. Fedorkov
      26.08.2019 14:05

      Я однажды потратил неделю на то, чтобы оптимизировать на ассемблере функцию, которая бы работала быстрее, чем на Си/gcc. Программа есть на гитхабе, но я делал это на спор, поэтому там только бинарники.

      А потом я переписал эту программу на C#/Mono, и она с ходу заработала ещё быстрее.

      А вчера я начал баловаться с Rust, и переписанная на нём программа заработала ещё в полтора раза быстрее.


  1. DistortNeo
    25.08.2019 22:54

    У C# (да и прочих IL-языков) никаких шансов пробиться в топ по производительности.

    Но это связано с компиляцией в процессе работы программы, а не с самим фактом существования IL-кода. Тот же LLVM, но со статической компиляцией, вполне себе быстр.


    Но главный вопрос — а нафига?

    Вот именно. Поначалу я пытался использовать ассемблер для SSE/AVX оптимизаций. Потом понял бесперспективность данного направления из-за необходимости писать ветки кода и для x86, и для x64 архитектуры и открыл для себя интринсики.


  1. Alex141
    26.08.2019 01:09

    У нас с другом была такая шутка. Что есть человек, который настолько любит ассемблер, что куда бы он ни устроился и на каком бы языке не писал, пишет код с ассемблерными вставочками :) И этим бесит коллег, потому что никто эти вставки не понимает)


    1. Asen
      26.08.2019 03:12

      Вставки по большому счету всем понятны, а вот то, как потом поддерживать код с такими вставками — коллегам не понятно


  1. ad1Dima
    26.08.2019 07:46

    Я просто положу это здесь


    1. ad1Dima
      26.08.2019 09:02

      Ну собственно ссыль на интринсики в .net core 3.0 docs.microsoft.com/en-us/dotnet/api/system.runtime.intrinsics.x86


  1. mayorovp
    26.08.2019 09:33

    Приведенная реализация InvokeAsm может в любой момент поломаться из-за GC. Ну хорошо, VirtualProtect работает надёжно потому что в блоке fixed — а дальше-то что помешает массиву байт быть перемещенным в другое место?


    1. Midiy Автор
      26.08.2019 10:49

      Да, действительно. По-хорошему надо бы InvokeAsm обернуть в метод, запрещающий сборку мусора так же, как это сделано в SafeInvokeAsm.

      Честно говоря, такое решение мне кажется не совсем элегантным, так что если кто-либо сможет предложить иной вариант борьбы с GC — с удовольствием почитаю.


      1. mayorovp
        26.08.2019 11:13

        Так ведь правильное решение уже подсказали выше: явно выделить кусок памяти вне кучи. Лучше даже через VirtualAlloc.


        А потом можно получить управляемый делегат через Marshal.GetDelegateForFunctionPointer. Только надо придумать как связать время жизни делегата и время жизни участка памяти, похоже придётся ещё и управляемый делегат налету компилировать, с вызовами DangerousAddRef и DangerousRelease...


      1. elmm
        26.08.2019 15:56

        А как на счёт GCHandle.Alloc с типом Pinned т после вызова сделать Free.
        Пока хэндл не будет освобождён, этот кусок памяти будет прибит гвоздями и gc не будет его трогать.


  1. grewishka
    26.08.2019 10:15

    В Visual Studio была подобная возможность для С++, но, вроде, ее убрали. Все равно оптимизацию современного компилятора не переплюнуть. Он и SSE и остальное сам все делает. Так что, не стоит вскрывать эту тему.


    1. DistortNeo
      26.08.2019 17:39

      Лет 7-8 назад качество компиляции VS оставляло желать лучшего. Код на интринсиках работал в 2-3 раза медленее вручную написанного на ассемблере даже с максимальными оптимизациями. А потом я открыл для себя интеловский компилятор.


      1. grewishka
        26.08.2019 19:13

        Да, примерно 7-8 лет назад можно было невооруженным взглядом увидеть разницу если во встроенном ассемблере написать, скажем, код для векторизации. Года четыре назад можно уже было только в лучшем случае сравниться с компилятором.


  1. Ununtrium
    26.08.2019 12:49

    1. Берем высокоуровневый кроссплатформенный язык с рантаймом, GC и прочим
    2. Выкидываем все вышеперечисленное и пишем на асме
    3. ???
    4. PROFIT!


  1. Doomer3D
    28.08.2019 01:11

    Немного запоздало, но все же оставлю ссылку на свой пост о генерации IL-кода: https://habr.com/ru/post/251765/.
    Пример там не абстрактный, а вполне рабочий, применяемый в реальных проектах.

    Ну а что касается ассемблера, использовать его в C# смысла не вижу, язык создан не для этого. Кроме того, теряется кроссплатформенность, которая является главной (на мой взгляд) фишкой .NET Core, а за ним будущее всего .NET.


    1. mayorovp
      28.08.2019 08:30

      Что-то там у вас тоже всё слишком сложно вышло, можно же использовать System.Linq.Expressions для той же цели.


    1. Midiy Автор
      28.08.2019 12:48

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


      1. Siemargl
        28.08.2019 23:52

        Может стоило сразу добавить в заголовок?

        А то мне за вопрос о смысле впаяли -15 =)


        1. Midiy Автор
          29.08.2019 00:10

          Мда, как-то слегка неловко получилось… :)
          Впрочем, я не ожидал, что кто-то может всерьёз воспринять это как попытку улучшить язык.