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.
- 287