How we got bored of boilerplate and added new features to C# to save time.

ExtendedCS

Artūras Šlajus

2023

What is a compiler, anyway?

  • A compiler is:
    • a program
    • that takes input in one format
    • and produces an output in another format
    • while checking for errors
  • For C# it takes in C# source code and produces IL instructions.

C# is an evolving language

C# 1.0

  • Basic features, such as classes, structs, interfaces, events, properties, delegates, exceptions, statements, and expressions.

C# 2.0

  • Generics
  • Partial types
  • Anonymous methods
  • Iterators (yield return)
  • Nullable types
  • Static classes
  • Delegate inference

C# is an evolving language

C# 3.0

  • Implicitly typed local variables (var)
  • Object and collection initializers
  • Auto-implemented properties
  • Anonymous types
  • Extension methods
  • Query expressions (LINQ)
  • Lambda expressions
  • Expression trees

C# is an evolving language

C# 4.0

  • Dynamic binding (dynamic keyword)
  • Named and optional arguments
  • Generic co- and contravariance
  • Embedded interop types

C# 5.0

  • Asynchronous methods (async/await keywords)
  • Caller information attributes

C# is an evolving language

C# 6.0

  • Auto-property initializers
  • Expression-bodied members
  • Null-conditional operators
  • String interpolation
  • nameof expressions
  • Index initializers
  • Exception filters
  • Await in catch and finally blocks
  • Default values for getter-only properties

C# is an evolving language

C# 7.0

  • Tuples and deconstruction
  • Discards
  • Local functions
  • Pattern matching
  • Ref locals and returns
  • Out variables
  • Literal improvements (binary literals, digit separators)

C# 7.1

  • Async main method
  • Default literal expressions
  • Inferred tuple element names

C# is an evolving language

C# 7.2

  • Reference semantics with value types (ref readonly, ref struct, readonly struct, in parameters)

C# 7.3

  • Enhanced generic constraints
  • Tuple equality
  • Overload resolution improvements

C# is an evolving language

C# 8.0

  • Nullable reference types
  • Asynchronous streams
  • Default interface methods
  • Pattern matching enhancements
  • Static local functions
  • Indices and ranges
  • Null-coalescing assignment
  • Using declarations

C# is an evolving language

C# 9.0

  • Top-level statements
  • Init-only setters
  • Record types
  • Improved pattern matching
  • Target-typed new expressions
  • Static anonymous functions
  • Target-typed conditional expressions
  • Covariant returns

Unity does not give you latest C#

Unity Version C# for Unity C# for .NET
2021.2+ 9.0 10.0
2020.2+ 8.0 9.0
2018.3+ 4 / 7.3 7.3
  • To get newer C# you must update Unity, which isn't always fun.
  • You also have to wait until Unity decides to support new C#.

We have solved that

  • ExtendedCS can be used in any Unity version to give you latest C# and the extra capabilities.

C# evolves too slowly for us!

  • C# is developing pretty rapidly.
  • However, they still have to think really hard what they are releasing, as there is no way back.
  • We don't have that problem! :)
  • Which means we can add new stuff much faster.

Make your own C#!

  • Thus, ExtendedCS was born with the features that we are missing from C# / Unity.
  • Maybe they will come to C# one day!
  • Until then, Extended CS is here to help us.

What ExtendedCS brings to the table

  • Various helpers that generate code.
  • Metaprogramming AKA macros.

Helpers here and there

  [Record] 
  public partial class NumParam : StringTemplateParam {
    /// <inheritdoc cref="VarName"/>
    public override VarName varName { get; }

    [LazyProperty] 
    public override NonEmpty<ImmutableArrayC<ParamValue.Type>> 
      supportedTypes => 
        NonEmpty.arrayC(ParamValue.Type.Numeric);

    /// <inheritdoc cref="Inflections"/>
    public readonly Inflections inflections;

    /// <inheritdoc cref="Inflector"/>
    public readonly Inflector inflector;
  }

Exhaustive matching

[Matcher] public interface ITicksError {}
public static partial class TicksError {
  /// <summary>Error that is expected and can be handled.</summary>
  [Record] public sealed partial class Expected : ITicksError {
    public readonly string error;
  }

  /// <summary>Error that is unexpected and should not have happened.</summary>
  [Record] public sealed partial class Unexpected : ITicksError {
    public readonly string error;
  }
}

Exhaustive matching

IValidateMatchJobHandlerResult onTicksParsingError(
  RedisStoredData redisData, Replay.ITicksError error
) => error.matchM(
  expected: expected => 
    ValidateMatchJobHandlerResult.Ignore.instance,
  unexpected: unexpected => 
    new ValidateMatchJobHandlerResult.FatalError(
      "Received redis data for had an unexpected error: " + 
      unexpected
    )
);

Implicits

using GenerationAttributes;

public static A Sum<A>(
  this IEnumerable<A> elements, 
  [Implicit] Numeric<A> numeric = default
) =>
  elements.Aggregate(
    numeric.Zero, 
    (a, b) => numeric.Add(a, b)
  );

Implicits

When you invoke this function, you can either:

  • Provide the argument explicitly, then it will behave as a regular function.

    var sum = new []{1, 2, 3}.Sum(Numeric.Integer);
  • Or omit the parameter, in which case the compiler will perform an implicit search to provide a value for the parameter at a compile time.
    var sum = new []{1, 2, 3}.Sum();

Implicits

The compiler first looks for any parameters marked as [Implicit] in the local function.

For example:

public int SumIntegers(
  int[] integers, 
  [Implicit] Numeric<int> numeric = default
) =>
  integers.Sum();

Will compile to:

public int SumIntegers(
  int[] integers, 
  [Implicit] Numeric<int> numeric = default
) =>
  integers.Sum(numeric);

Implicits

If the compiler can not find a suitable value in the local scope, it tries the class / struct scope.

For example:

class NumberCruncher {
  // This can also be static.
  [Implicit] readonly Numeric<int> IntNumeric = /* ... */;

  public int SumIntegers(int[] integers) => integers.Sum();
}

Will compile to:

class NumberCruncher {
  [Implicit] readonly Numeric<int> IntNumeric = /* ... */;

  public int SumIntegers(int[] integers) => integers.Sum(IntNumeric);
}

Implicits

If class / struct scope fails as well, it tries to find a public static field, property or method to provide the value.

For example:

public static class Numerics {
  [Implicit] public static readonly Numeric<int> IntNumeric = /* ... */;
}

class NumberCruncher {
  public int SumIntegers(int[] integers) => integers.Sum();
}

Will compile to:

public static class Numerics {
  [Implicit] public static readonly Numeric<int> IntNumeric = /* ... */;
}

class NumberCruncher {
  public int SumIntegers(int[] integers) =>
    integers.Sum(Numerics.IntNumeric);
}

Implicits

  • If it can not find a value, a compilation error is emitted:

    Example.cs(24, 16): [Implicits02] No matching implicits found for parameter 'numeric' of type 'Numeric<int>' on operation 'SumIntegers'

Macro Library

  • Macros give us superpowers!
  • We can add functionality into C# which it normally does not support.

[DelegateToInterface]

interface MyInterface {
  int GetAge(Person person);
}

[DelegateToInterface(
  delegatedInterface = typeof(MyInterface),
  delegateTo = nameof(implementer)
)]
public partial class TestClass {
  public MyInterface implementer;
}

[DelegateToInterface]

public partial class TestClass : MyInterface {
  public int GetAge(Person person) => 
    implementer.GetAge(person);
}

[Mixin]

public abstract partial class ToBeMixedIn {
  /// <summary>publicField</summary>
  public int publicField = 8;

  /// <summary>protectedField</summary>
  protected int protectedField = 5;

  /// <summary>privateField</summary>
  [SerializeField] int privateField = 4;
}

[Mixin(typeToMixin: typeof(TypeToMixIn))]
public partial class ToBeMixedInto {}

[Union]

public enum TestEnum { Int1, Str, Int2 }

// If you have duplicate case types, you must specify the 
// enum type.
[Union(
  cases: new []{typeof(int), typeof(string), typeof(int)},
  caseEnumType: typeof(TestEnum)
)]
public partial class UnionTest2 {
  public static void test() {
    default(UnionTest2).voidFoldM(
      onInt1: i => Console.WriteLine(i),
      onString: str => str.Clone(),
      onInt2: a => {
        Console.WriteLine(a);
      }
    );
  }
}

[SubsetEnum]

[SubsetEnum(
  name: "ADEnum", docSummary: "test1", 
  cases: new[]{nameof(A), nameof(D)}
)]
public enum FullEnum : byte {
  A, B, C, D
}

[EnumUnion]

public enum BuildRegularType { 
  Development = 0, Stage = 1, Production = 2
}
public enum BuildSpecialType {
  MapEditor = 100
}

[EnumUnion(toMerge: new[] { 
  typeof(BuildRegularType), 
  typeof(BuildSpecialType)
})]
public static partial class BuildType {}

[LambdaInterface]

  [LambdaInterface] interface IPartB {
    string name { get; }
    int sharedNumber { get; }
    void doSomethingElse(bool a, bool b);
    void shared(bool a);
  }
  
public sealed class LambdaIPartB : IPartB {
  public string name { get; }
  public int sharedNumber { get; }

  public readonly System.Action<bool, bool> 
    _doSomethingElse;
  public void doSomethingElse(bool a, bool b) => 
    _doSomethingElse(a, b);
  
  public readonly System.Action<bool> _shared;
  public void shared(bool a) => _shared(a);
}

[TypeSafeEnumWrapper]

  [TypeSafeEnumWrapper(
    typeWithConstants = typeof(RewiredConsts.Action),
    enumUnderlyingType = typeof(int)
  )]
  public readonly partial struct RewiredActionT {
    public override string ToString() => 
      $"{Macros.classShortName}({value})";
  }

[ReadOnlyMirror]

  public class ClassMutable {
    public int x;
    public string foo;
    public IList<string> fooOverriden;
  }
    
  [ReadOnlyMirror(
    of = typeof(ClassMutable),
    overridenFieldNames = new []{
      nameof(ClassMutable.fooOverriden)
    },
    overridenFieldTypes = new []{
      typeof(List<string>)
    }
  )]
  public sealed partial class ClassImmutable {}

And more!

Macros

Macros are programs that run on the compilation time.

 

We currently have 3 types of macros:

  • Scriban Replacement Macros.
  • Scriban Attribute Macros.
  • C# Attribute Macros.

Scriban replacement macros

  • Allows you to replace expressions, statements and variable assignments.

[ExpressionMacro]

public static class AnyExts {
  /// <summary>
  /// Returns "expression=evaluated expression" string.
  /// </summary>
  [ExpressionMacro(@"(""{{a}}="" + ({{a}}))")]
  public static string echo<A>(this A a) => 
    throw new MacroException();
}

var error = 
  $"No such {index.echo()}. {list.Count.echo()}";

var error = 
  $"No such {("index=" + (index))}. " +
  $"{("list.Count=" + list.Count)}";

var error = 
  $"No such index={index}. list.Count={list.Count}";

[StatementMacro]

public static class NullableExts {
  [StatementMacro(@"
var {{ uniqueId }} = {{ opt }};
if (({{ uniqueId }}).HasValue) {
  {{ inline 'ifSome' uniqueId + '.Value' }};
} else {
  {{ inline 'ifNone' }};
}
")]
  public static void voidFoldM<A>(
    this A? opt, Action ifNone, Action<A> ifSome
  ) => 
    throw new MacroException();
}

[StatementMacro]

void run(int? maybeValue) {
  maybeValue.voidFoldM(
    ifNone: () => {
      Console.WriteLine("No value.")
    }, 
    ifSome: value => {
      Console.WriteLine($"Received value: {value}");
    }
  );
}

[StatementMacro]

void run(int? maybeValue) {
  var id_542_671 = maybeValue;
  if ((id_542_671).HasValue) {
    _LOCAL_ifSome_601_670(id_542_671.Value);
  }
  else {
    Console.WriteLine("No value.");
  }

  void _LOCAL_ifSome_601_670(int value) {
    Console.WriteLine($"Received value: {value}");
  }
}

[VarMacro]

public readonly struct Result<TValue, TError> {
  [VarMacro(@"
    var {{ uniqueId }} = {{ self }};
    if ({{ uniqueId }}.IsError) 
      return {{ uniqueId }}.__UnsafeError;
    {{ varType }} {{ varName }} = 
      {{ uniqueId }}.__UnsafeValue;
  ")]
  public TValue ValueOr_RETURN() => 
    throw new MacroException();
    
  public static implicit operator Result<TValue, TError>(
    TError error
  ) => new(error);

  public static implicit operator Result<TValue, TError>(
    TValue value
  ) => new(value);
}

[VarMacro]

Result<int, string> AddResults(
  Result<int, string> num1Result, 
  Result<int, string> num2Result
) {
  var num1 = num1Result.ValueOr_RETURN();
  var num2 = num2Result.ValueOr_RETURN();
  return num1 + num2;
}

[VarMacro]

static Result<int, string> AddResults(
  Result<int, string> num1Result, 
  Result<int, string> num2Result
) {
  var id_1136_1163 = num1Result;
  if (id_1136_1163.IsError)
    return id_1136_1163.__UnsafeError;
  int num1 = id_1136_1163.__UnsafeValue;
  
  var id_1181_1208 = num2Result;
  if (id_1181_1208.IsError)
    return id_1181_1208.__UnsafeError;
  int num2 = id_1181_1208.__UnsafeValue;
  
  return num1 + num2;
}

Scriban attribute macros

  • Allows you to define attributes that will run Scriban code when used.
  • Lots of macros in the Macro library are defined with Scriban.

Scriban attribute macros

[
  AttributeMacro(@"
public {{ 
if field.is_static 
  'static' 
end 
}} void EDITOR_set{{ field.name | rename | pascal_case }}(
  {{ field.type | type_reduced_name }} value
) => {{ field.name }} = value;
  "), 
  AttributeUsage(AttributeTargets.Field)
]
public class EditorSetterAttribute : Attribute {}

class MyCharacterData {
  [SerializeField, EditorSetter] CharacterRole _type;
}

Scriban attribute macros

[
  AttributeMacro(@"
  
public static TimeSpan {{ ourMethodBaseName }}(this int v) => 
  TimeSpan.{{ timeSpanMethodName }}(v);
  
public static TimeSpan {{ ourMethodBaseName }}s(this int v) => 
  TimeSpan.{{ timeSpanMethodName }}(v);
  
  "),
  AttributeUsage(
    AttributeTargets.Class, AllowMultiple = true
  )
]
class GenTimeSpanHelpersAttribute : Attribute {
  public string ourMethodBaseName;
    
  public string timeSpanMethodName;
}

Scriban attribute macros

[
  GenTimeSpanHelpers(
    ourMethodBaseName = "milli", 
    timeSpanMethodName = nameof(TimeSpan.FromMilliseconds)
  ),
  GenTimeSpanHelpers(
    ourMethodBaseName = "second", 
    timeSpanMethodName = nameof(TimeSpan.FromSeconds)
  ),
  GenTimeSpanHelpers(
    ourMethodBaseName = "minute", 
    timeSpanMethodName = nameof(TimeSpan.FromMinutes)
  ),
  GenTimeSpanHelpers(
    ourMethodBaseName = "hour", 
    timeSpanMethodName = nameof(TimeSpan.FromHours)
  ),
  GenTimeSpanHelpers(
    ourMethodBaseName = "day", 
    timeSpanMethodName = nameof(TimeSpan.FromDays)
  ),
] 
public static partial class TimeSpanExts {}

C# macros

  • Allows you to write macros with C#.
  • Needs to be defined in a separate macro project.
  • You can do anything C# can do, including:
    • Connecting to SQL database to validate your queries on build time.
    • Downloading a file and generating code from that.
    • Writing extra files to the file-system.
  • More complicated, as you have to deal with Roslyn compiler APIs directly.

ExtendedCS

By Artūras Šlajus

ExtendedCS

How we got bored of boilerplate and added new features to C# to save time.

  • 214