experiments
March 28

Отбитый дотнет

Настало время познакомить маленьких любителей шарпа с грязной стороной их любимой технологии — шатаем .NET!

Вводная

Платформа .NET и самый популярный язык для нее C#, как и Java, имеют четкую и однозначную спецификацию, сама разработка ведется на очень высоком уровне, при существенной поддержке как самой корпорации Microsoft, так и c участием сторонних контрибуторов.

Как и Java — .NET является законченным промышленным решением для практически любого типа разработки, как и Java — C# считается безопасным.

Хотя я до сих пор не понимаю — как и чем может быть опасен для человека язык программирования.

Если только речь не про Haskell.

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

Тестовая среда

Ради большего угара,в качестве тестовой среды была взят .NET Core 9 для Linux, чтобы сократить объем причитаний, отмазок и оговорок про особенности Windows и тяжелое прошлое.

К сожалению в Linux версии дотнета за каким-то хером успели выпилить поддержку компиляции отдельных .cs файлов, оставив только сборку проекта целиком с помощью скрипта сборки.

Создавать который для каждого однострочника — точно перебор.

Для исправления ситуации пришлось использовать специальный bash-скрипт, для вызова компилятора в ручном режиме:

#!/bin/bash

#dotnethome=`dirname "$0"`
dotnethome=`dirname \`which dotnet\``
sdkver=$(dotnet --version)
fwkver=$(dotnet --list-runtimes | grep Microsoft.NETCore.App | awk '{printf("%s", $2)}')
tfm="net${fwkver%.*}"
dotnetlib=$dotnethome/packs/Microsoft.NETCore.App.Ref/$fwkver/ref/$tfm

if [ "$#" -lt 1 ]; then
	dotnet $dotnethome/sdk/$sdkver/Roslyn/bincore/csc.dll -help
	exit 1
fi
if ! test -f "csc-$fwkver.rsp"; then
  for f in  $dotnetlib/*.dll; do
	echo -r:$(basename $f) >> /tmp/csc-$fwkver.rsp
  done
fi

for arg in "$@"
do
	if [[ "$arg" == *"out:"* ]]; then
	  prog="${arg:5}"
	  break
	fi
	if [[ "$arg" == *".cs" ]]; then
	  prog="${arg%.*}.dll"
	fi


done
dotnet $dotnethome/sdk/$sdkver/Roslyn/bincore/csc.dll -nologo -out:"$prog" -lib:"$dotnetlib" @/tmp/csc-$fwkver.rsp $* 
if [ $? -eq 0 ]; then
  if test -f "$prog"; then
    if [[ "$*" != *"t:library"* ]] && [[ "$*" != *"target:library"* ]]; then
	if ! test -f "${prog%.*}.runtime.config"; then
		echo "{
  \"runtimeOptions\": {
    \"framework\": {
      \"name\": \"Microsoft.NETCore.App\",
      \"version\": \"$fwkver\"
    }
  }
}"  > "${prog%.*}.runtimeconfig.json"
	fi
    fi
  fi
fi

Соответственно все приведенные ниже примеры собирались с помощью этого замечательного bash-скрипта — таков путь.

Дичь первая: минимал

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

Sharplab , твит автора.

Так:

{}

В работе это выглядит так:

Программа, которая не делает ничего, но собирается и запускается, занимая при этом 3.5kb места — видимо в Microsoft наконец поняли дзен и достигли высшего просвящения.

Дичь вторая: изменяемый «read only»

Система наследования общих классов в C# не настолько хорошо выверена и продумана как в Java, поэтому временами получаются нехорошие вещи:

using System;
using System.Collections.Generic;

var list = new List<int>{1, 2, 3, 4};

IReadOnlyList<int> readonlyList = list;

// вызовет ошибку error CS1061 
// readonlyList.Add(5);

// сработает
((List<int>)readonlyList).Add(5);

// 5
Console.WriteLine(readonlyList.Count);

Sharplab, отличное объяснение происходящего на StackOverflow и даже видео:

Вариант без кастования действительно не дает собрать такой код:

Но с кастованием все замечательно собирается и работает:

Дичь третья: изменяемые константы

Оставлю для истории, поскольку в новых версиях дотнета уже не работает:

using System;
using System.IO;

Console.WriteLine(Path.DirectorySeparatorChar); // печатает '\'

var f = (in char x) => { /* нельзя изменить 'x' внутри этого блока */ };
f = (ref char x) => { x = 'A'; }; // но возможно тут!

f(Path.DirectorySeparatorChar);

Console.WriteLine(Path.DirectorySeparatorChar); // печатает'A'

Sharplab (обратите внимание на версию дотнета), твит автора.

Дичь четвертая: упоротый async await

Рубрика «вопросы с собеседования», расскажите что делает этот код:

async async async(async async) => 
        await async;

Да, это действительно работающий код на C#, просто не весь — для работы двух строк такой дичи требуется поддерживающая простыня из костылей:

class await : INotifyCompletion {
    public bool IsCompleted => true;
    public void GetResult() { }
    public void OnCompleted(Action continuation) { }
}

[AsyncMethodBuilder(typeof(builder))]
class async { 
    public await GetAwaiter() => throw null;
}

class builder
{
    public builder() { }
    public static builder Create() => new();
    public void SetResult() { }
    public void SetException(Exception e) { }
    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine => throw null;
    public async Task => null;
    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine => throw null;
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine => throw null;
    public void SetStateMachine(IAsyncStateMachine stateMachine) => throw null;
}

Более детальный gist с построчным объяснением, твит автора и Sharplab.

Пруф корректной компиляции и запуска:

Еще более упоротый вариант:

[async, async<async>] async async async([async<async>, async] (async async, async) async)
        => await async.async;

Твит автора и полная версия кода на sharplab.

Тут аналогичный принцип — ради отбитого однострочника пришлось ваять кучу поддерживающего эту дичь кода.

Дичь пятая: вызов без инициализации

Еще одна отбитая вещь, ненужная и невозможная в Java но по какой-то неведомой причине доступная в дотнете:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Reflection;
using System.Runtime.Serialization;

namespace Rextester
{
    public class Program
    {
        public static void Main(string[] args)
        {
      Evil e = (Evil)FormatterServices.GetUninitializedObject(typeof(Evil));
      e.PrintValue();
        }
    }
    
    public class Evil
    {
        private int _value = 4;
        
        private Evil() 
        {
            Console.WriteLine("constructed");
        }
        
        public void PrintValue()
        {
            Console.WriteLine("Value is {0}", _value);
        }
    }
}

Взято отсюда.

Пруф работы:

Чем это плохо? Тем что вы получаете экземпляр класса, но без инициализации, задуманной автором.

При этом работают методы.

Еще один, не менее отбитый вариант с ручным вызовом приватного конструктора:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Reflection;

namespace Rextester
{
    public class Program
    {
        public static void Main(string[] args)
        {
  Evil e = (Evil)typeof(Evil)
  .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance).Single()
  .Invoke(null);
        }
    }
    
    public class Evil
    {
        private Evil() 
        {
            Console.WriteLine("constructed");
        }
    }
}

Источник.

Да, это тоже работает:

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

Дичь шестая: угар по гречески

Код C# ниже является полностью валидным и замечательно компилируется, хотя и требует ключ «/unsafe»:

using System;
unsafe class Program
{
    delegate void bar(int* i);
    static Index ƛ(bar β) => default;
    static void Main(string[] args)
    {
        int[] ω = { };
        int Ʃ = 42;
        int? Φ = 10;
        var ϼ = ω;
        
ϼ=ω[ƛ(β:Δ=>Φ??=ω[^++Ʃ]/Ʃ|*&Δ[-0%Ʃ]>>1^Φ??0!&~(δ:Ʃ,^Φ..).δ)..(1_0>.0?Ʃ:0b1)];
    }
}

Твит автора, Sharplab.

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

Пруф:

Дичь седьмая: рефлексия без рефлексии

Цитируя коммент автора:

How about changing private fields without unsafe or reflection? I bet you thought C# was type safe. Nothing is truly black and white.

становится очевидно, что еще один все понял и достиг просвящения:

using System.Reflection;
using System.Runtime.InteropServices;
using System;

public class Alpha
{
    public int A;
    private int B;

    public Alpha()
    {
        this.A = 1337;
        this.B = 42;
    }

    public void PrintB()
    {
        Console.WriteLine("My private field B is " + this.B);
    }
}

public class Bravo
{
    public int A;
    public int B;
}

[StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct Union
{
    [FieldOffset(0)] public Alpha AsAlpha;
    [FieldOffset(0)] public Bravo AsBravo;
}

internal class Program
{
    static void Main(string[] args)
    {
        Alpha alpha = new Alpha();

        // напечатает 42.
        alpha.PrintB();

        // Re-interpret as Bravo and change B to 1234.
        Union union = new Union();
        union.AsAlpha = alpha;
        union.AsBravo.B = 1234;

        // напечатает 1234.
        alpha.PrintB();
    }
}

Reddit.

Да, это действительно работает:

Возможность творить такое без использования спецального API для рефлексии — неожиданный сюрприз для анализаторов кода.

Дичь восьмая: строка, которая хотела стать числом

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

using System;

// строгая типизация, говорите?
String s = 42;

// печатает 13
Console.WriteLine(s); 

class String {
    public override string ToString() => "13";
    public static implicit operator String(int i) => new();
}

Источник на Reddit, полная версия на Sharplab и статья с описанием.

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

представьте, что класса String ниже нет, а вам задают вопрос «соберется такой код или нет»

Дичь девятая: округление "по пацански"

Посмотрите в глаза на этот замечательный код:

using System;
float x = 0.4f;
float y = 0.6f;
Console.WriteLine((double)x + (double)y == 1); // False
Console.WriteLine((float)((double)x + (double)y) == 1); // True

Твит автора, объяснение и тест на Sharplab.

Пруф:

Дичь десятая: в стихах!

Взгляните на этот абсолютно рабочий и валидный код на C#:

var x = i is null or not null or add 
		or and and alias or ascending and args 
		or async and await or by and descending 
		or dynamic and equals or from and get 
		or global and group or init and into 
		or join and let or managed and nameof 
		or nint or notnull and nuint or on and 
		or or orderby and  record and remove 
		or select and set or unmanaged 
		and value or var and when or where and with or yield;
    
Console.WriteLine(x);

Твит автора и Sharplab с полной версией.

Да, тут снова простыня поддерживающего кода (см пример на Sharplab), без которого ничего не работает.

Пруф что все работает:

Еще

Сих моих нет разбирать всю возможную дичь, творимую в дотнете, поэтому ниже одной строкой по самому интересному.

1. Замечательная презентация «.NET 052: Abusing C#, Calendars, Epochs and the .NET Functions Framework with Jon Skeet» с отдельным репозиторием для хранения дичи.

2. Gist с небольшим примером изнасилования операторов:

3. Обсуждение на Reddit, посвященное самой отбитой дичи на C#:

What's the most insane thing you can do in C#?

4. Обсуждение на StackOverflow, посвященное подмене метода в работающем приложении на С#:

Dynamically replace the contents of a C# method?

5. Еще один Gist с реализацией stdin/stdout в стиле C++:

6. Подборка дичи в репозитории на Github, откуда я взял часть материала.

7. Еще одна подборка, но уже в виде статьи.