A program is executed. A process is created by the OS, and within its single thread it starts loading code to execute. In a .NET app, a single AppDomain is created by the CLR. The app’s executing assembly (the .EXE) is loaded into this AppDomain and begins execution. The app can spawn new processes, create AppDomains, load other assemblies into these domains, and then create new Threads to execute code in any of these AppDomains.
C# Code > C# Compiler > IL > .NET Runtime > JIT Compiler > Machinecode > Execution
When you compile your C# code it gets turned into IL that the CLR understands. The IL is the same for all languages running on top of the CLR. Metadata from every class and every method is included in the PE header of the resulting executable (dll or exe). If you’re producing an executable the PE Header also includes a conventional bootstrapper which is in charge of loading the CLR (Common language runtime) when you execute you executable. The bootstraper initializes the CLR (mainly by loading the mscorlib assembly) and instructs it to execute your assembly. The CLR executes your main entry. Now, classes have a table which hold the addresses of the method functions, so that when you call MyMethod, this table is searched and then a corresponding call to the address is made. Upon start ALL entries for all tables have the address of the JIT compiler. When a call to one of such method is made, the JIT is invoked instead of the actual method and takes control. The JIT then compiles the IL into actual machine code for the appropiate architecture. Once the code is compiled the JIT goes into the method table and replaces the address with the one of the compiled code, so that every subsequent call no longer invokes the JIT. Finally, the JIT handles the execution to the compiled code.
https://stackoverflow.com/a/5110302 has great diagram on how the JIT works.
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
switch (item)
{
case 0:
break;
case int val:
sum += val;
break;
case int[] array when array.Length > 0:
sum += Sum(array);
break;
case null:
break;
default:
throw new Exception("unknown item");
}Orientation ToOrientation(Direction direction) => direction switch
{
Direction.Up => Orientation.North,
Direction.Right => Orientation.East,
Direction.Down => Orientation.South,
Direction.Left => Orientation.West,
_ => throw new ArgumentOutOfRangeException(nameof(direction)),
};virtual: have an implementation and provide the derived
classes with the option of overriding it.abstract: do not provide an implementation and force
the derived classes to override the method.override: overrides the virtual method in the base
class (can specify sealed to prevent further
overriding)new: the method in the derived class hides the one in
the base classpublic static void UseParams(params int[] x) => x.sum();Can be static (non-capturing):
data.OrderBy(static e => e);
ref: arg may be modifiedin: arg cannot be modifiedout: arg must be modifiedpublic double Hours
{
get { return _seconds / 3600; }
set {
if (value < 0 || value > 24)
throw new ArgumentOutOfRangeException();
_seconds = value * 3600;
}
}
// Indexers are written as properties
public T this[int i]
{
get => arr[i];
set => arr[i] = value;
}Delegate defines a method signature.
// creates a delegate with the name Parse and signature here
delegate Expression Parse(string s);
// ParseInt satisfies the delegate signature
static Expression ParseInt(string s) => new Expression(int.Parse(s));
static Expression ToExpression(Parse p, string s) => p(s);Action<>, Func<>, Predicate<> = Func<T, bool>
are built-in generic delegates.
T s = (T)o; Use when something should definitely be the
other thing (throws InvalidCastException if not).T s = o as T; Use when something might be the other
thing (assigns null if not).public class AClass<T1,T2> : BaseClass
where T1: IInterface
where T2: notnullclass B { }
class A : B { }
B b = ... ;
A a = ... ;
b.GetType() == typeof(B); // true
a.GetType() == typeof(A); // true
a.GetType() == typeof(B); // false
typeof(A) == typeof(B); // false
b is B; // true
a is B; // trueEnable implicit reference conversion for array types, delegate types, and generic type arguments. Covariance preserves assignment compatibility and contravariance reverses it.
// Assignment compatibility.
string str = "test";
// An object of a more derived type is assigned to an object of a less derived type.
object obj = str;
// Covariance.
IEnumerable<string> strings = new List<string>();
// More derived type argument is assigned to a less derived type.
// Assignment compatibility is preserved.
IEnumerable<object> objects = strings;
// Contravariance.
Action<object> actObject = SetObject;
// Less derived type argument is assigned to a more derived type.
// Assignment compatibility is reversed.
Action<string> actString = actObject;Viewing subtypes as subsets, covariance allows you to go out of the subset. Contravariance allows you to go in.
public interface ICovariant<out T> {}
public interface IContravariant<in T> {}Number formatting
0: either number or 0#: either number or nothing,: inserts group separator (‘,’) or scales number (each
trailing , divides by 1000)0,,.00#M is a millions format.
class Name
{// ...
public void Deconstruct(out string first, out string last)
{
first = First;
last = Last;
}
}
var name = new Name("x", "y");
var (x, y) = name;Useful for initializing any static fields associated with a type (or any other per-type operations) - useful in particular for reading required configuration data into readonly fields.
class Ex
{
// Static variable that must be initialized at run time.
static readonly long baseline;
// Static constructor is called at most one time, before any
// instance constructor is invoked or member is accessed.
// Can be a performance hit...
static Ex()
{
baseline = DateTime.Now.Ticks;
}
public Ex() {}
}Used to be known as destructors. Automatically called when class instance is being cleaned up by GC. Use SafeHandle or derived classes to wrap unmanaged handles.
As the implementer of a class, if you hold managed resources that ought to be disposed, you implement Dispose. If you hold native resources, you implement both Dispose and Finalize, and both call a common method that releases the native resources.
If user forgot to call Dispose and if the class have Finalize implemented then GC will make sure it gets called.
Structs can be more performant when <= 16 bytes. Avoid if they will be passed in to a method expecting an interface (requires boxing).
d.Add(k,v): add, but throws if item already existsd[k] = v: add or updateInitialization:
// Calls Add() for each entry.
var dict = new Dictionary<int, string>()
{
{ 101, "A" },
{ 102, "B" },
{ 103, "C" }
};
// Calls [] for each entry.
var dict = new Dictionary<int, string>()
{
[101] = "A",
[102] = "B",
[103] = "C"
};Other methods:
d.TryGetValue(key, out var value);
d.Keys;
d.Values;
d.Remove(key);
d.Clear();s.Push(x);
var top = s.Peek();
var top = s.Pop();
var has = s.TryPeek(out var top);
var has = s.TryPop(out var top);q.Enqueue(x);
var x = q.Peek();
var x = q.Dequeue();
var has = q.TryPeek(out var x);
var has = q.TryDequeue(out var x);Doubly-linked
var node = new LinkedListNode<T>(val);
var node = l.First();
var node = l.Last();
l.AddFirst(node);
l.AddLast(node);
l.Remove(node);
l.RemoveFirst();
l.RemoveLast();
var node = l.Find(val);
var node = l.FindLast(val);Min-heap
var pq = new PriorityQueue<TVal, TPriority>();
pq.Enqueue(val, priority);
var val = pq.Peek();
var val = pq.Dequeue();
var val = pq.EnqueueDequeue(val, priority);
var has = pq.TryPeek(out var val, out var priority);
var has = pq.Dequeue(out var val, out var priority);https://stackoverflow.com/questions/935621/whats-the-difference-between-sortedlist-and-sorteddictionary
IEnumerable<T> just has a
GetEnumerator() method that returns an
IEnumerator<T> which has
bool MoveNext()T Current which initially points to
nullstatic IEnumerable<T> ZeroOne(T x)
{
yield break;
yield return x;
}IEnumerable<T>: for working with sequences that
are iterated in-memory. The logic can be provided as a
function/delegate, i.e. Func<T>. Does not support
lazy-loading.IQueryable<T> : IEnumerable<T>: allows for
out-of-memory things like a remote data source, such as a database or
web service. The logic of the query has to be represented in data such
that the LINQ provider can convert it into the appropriate form,
i.e. Expression<Func<T>> instead of
Func delegatespublic interface IQueryable : IEnumerable
{
Type ElementType { get; }
Expression Expression { get; }
IQueryProvider Provider { get; }
}Properties:
IQueryable<T> object as a runtime-traversable AST
that can be understood by the given query provider. Usually not executed
within the C# program, but is translated first (say into SQL).Func<int, int, int> function = (a,b) => a + b; // lambda
Expression<Func<int, int, int>> expression = (a, b) => a + b; // expression treeThe expression tree consists of four main properties:
Body: contains the body of the expression and its
information.Parameters: contains information about the parameters
of the lambda expression.NodeType: contains information about the different
possible types of expression nodes, such as those that return constants,
those that return parameters, those that decide if one value is less
than another (<), those that decide if one is greater than another (
>), those that add values together (+), etc.Type: Gets the static type of the expression. In this
case, the expression is of typeReturnType
Func <int, int, int>.https://blexin.com/en/blog-en/linq-in-depth-advanced-features/
var querySyntax =
from customer in customers
let years = GetYears(customer) // nice!
where years > 5
orderby years
select customer.Name;
var methodSyntax = customers
.Select(customer => //anonymous type
(
Years: GetYears(customer),
Name: customer.Name
)
.Where(x => x.Years > 5)
.OrderBy(x => x.Years)
.Select(x => x.Name);xs.Aggregate(0, (a, x) => a+x);
// deferred execution, unlike ToLookup
var results = persons.GroupBy(
p => p.PersonId, // key
p => p.car, // elements
(key, g) => new { PersonId = key, Cars = g.ToList() }); // resultsA lock allows only one thread to invoke an operation at a time. A semaphore allows a predefined number of threads (specified in code) to invoke the method at a time.
Interlocked class provides methods to performing atomic
operations on shared variables.
[ThreadStatic] attribute can be added to static fields.
The CLR creates isolated versions of the same variable in each thread.
The fields are created on Thread Local Storage so every thread has it
own copy of the field i.e the scope of the fields are local to the
thread.
https://stackoverflow.com/questions/5227676/how-does-the-threadstatic-attribute-work
Thread.CurrentThread.IsBackground
A lock is specific to the AppDomain, while Mutex to the Operating System allowing you to perform inter-process locking and synchronization (IPC). Lock is built with mutexes: prefer lock if possible.
Sync primitives in System.Threading:
Barrier Enables multiple threads to work on an
algorithm in parallel by providing a point at which each task can signal
its arrival and then block until some or all tasks have arrived.CountdownEvent Simplifies fork and join scenarios by
providing an easy rendezvous mechanism.ManualResetEventSlim A synchronization primitive
similar to System.Threading.ManualResetEvent. ManualResetEventSlim is
lighter-weight but can only be used for intra-process
communication.SemaphoreSlim A synchronization primitive that limits
the number of threads that can concurrently access a resource or a pool
of resources.SpinLock A mutual exclusion lock primitive that causes
the thread that is trying to acquire the lock to wait in a loop, or
spin, for a period of time before yielding its quantum. In scenarios
where the wait for the lock is expected to be short, SpinLock offers
better performance than other forms of locking.SpinWait A small, lightweight type that will spin for a
specified time and eventually put the thread into a wait state if the
spin count is exceeded.async Task WorkerMainAsync()
{
var sema = new SemaphoreSlim(10);
var tasks = new List<Task>();
while (DoMore())
{
// WaitAsync produces a task that will be completed when that thread has been given "access" to that token.
// await-ing that task lets the program continue execution when it is "allowed" to do so.
await sema.WaitAsync();
tasks.Add(Task.Run(() =>
{
DoWork();
sema.Release();
}));
}
await Task.WhenAll(tasks);
}
// Enter semaphore by calling Wait or WaitAsync
SemaphoreSlim.Wait()
// Execute code protected by the semaphore.
SemaphoreSlim.Release()Simplifies the process of adding parallelism and concurrency to applications. The TPL:
A task resembles a thread or ThreadPool work item but at a higher level of abstraction. Tasks provide two primary benefits:
TPL is the preferred API for writing multi-threaded, asynchronous, and parallel code in .NET.
Comparison to threads:
Task.Start() schedules a task to run on the the
ThreadPool (it joins a queue)var tnew = new Task(() => { ... });
tnew.Start();
var trun = Task.Run(() => { ... });
Task.WaitAll(tnew, trun);
// Exceptions are propagated when you use one of the static or instance Task.Wait methods
// new eg
Tast<string>[] tasks = urls.Select(url => DownloadStringAsync(url)).ToArray();
try
{
string[] pages = await Task.WhenAll(tasks);
...
}
catch(Exception)
{
foreach(var faulted in tasks.Where(t => t.IsFaulted))
{
... // work with faulted and faulted.Exception
}
// alternatively, in sync code:
Task t = Task.WhenAll(tasks);
t.Wait(); // r.Result();
}
Creating and running tasks:
// Creating and running tasks implicitly
Parallel.Invoke(() => DoWork(), () => DoWork2()); // both run concurrently
/// Always uses the default task scheduler
Task t = Task.Run( () => Console.WriteLine("hi"));
t.Wait();
Task t = new Task( () => Console.WriteLine("hi"));
t.Start();
Console.WriteLine($"{t.Status}");
t.Wait(); // to ensure that the task completes execution
// Method to create and start a task in one operation
// Can use to pass state into a task, that can be retrieved through Task.AsyncState
Task.Factory.StartNew(
(Object o) => ((Target)o).Value = 5,
new Target(value: 2) );
// Note when using lambdas, they can capture outer state.
// The typed version Factory has a .Result property
var tasks = {
Task<Double>.Factory.StartNew(() => DoCalc(1)),
Task<Double>.Factory.StartNew(() => DoCalc(9))
};
var sum = tasks.Sum(t => t.Result);TaskCreationOptions
LongRunning: hint that oversubscription (more threads
than HW threads) might be warranted, so that other threads can progress
when some threads are blockedPreferFairness: hint that tasks scheduled earlier
should run earlierAttachedToParent: attach to parent taskDenyChildAttach: prevent other tasks from attaching to
this taskCreating task continuations:
var getData = Task.Factory.StartNew( () => new[] { 42, 5, 9 });
var sumData = getData
.ContinueWith( (x) => x.Result.Sum() )
.ContinueWith( (x) => Console.WriteLine(x.Result));
sumData.Wait();
// ContinueWhenAll
// ContinueWhenAny
// can use TaskContinuationOptions.OnlyOnFaulted to log exceptionsCreating detached child tasks:
var parent = Task.Factory.StartNew(() =>
{
Console.WriteLine("Parent task beginning.");
// Child isn't synchronized to parent
var child = Task.Factory.StartNew(() =>
{
Thread.SpinWait(5000);
Console.WriteLine("Detached child task completed.");
});
});
parent.Wait(); // doesn't wait for childCreating child tasks (to express structured task parallelism):
var parent = Task.Factory.StartNew(() =>
{
Console.WriteLine("Parent task beginning.");
// Child isn't synchronized to parent
var child = Task.Factory.StartNew(() =>
{
Thread.SpinWait(5000);
Console.WriteLine("Detached child task completed.");
}, TaskCreationOptions.AttachedToParent);
});
parent.Wait(); // waits for all attached child tasks to finishWaiting for tasks to finish by blocking synchronously:
Task.WaitTask.WaitAllTask.WaitAnyWait will throw any exceptions raised by a task, even if
the task is completed. Typically, you would wait for a task for one of
these reasons:
Others
Task.Delay: produces a Task object that finishes after
the specified time
Task.FromResult: holds a pre-computed resultException handling:
All exceptions are wrapped in an AggregateException and
propagated back to the thread that joins with the task. Using
Wait*, Result in a try/catch can allow you to
handle the exception. The joining thread can also handle exceptions by
accessing the Exception property before the task is GCd. By accessing
this property, you prevent the unhandled exception from triggering the
exception propagation behavior.
Task cancellation:
static async Task Main()
{
var tokenSource = new CancellationTokenSource();
CancellationToken ct = tokenSource.Token;
var task = Task.Run(() =>
{
// Were we already canceled?
ct.ThrowIfCancellationRequested();
while (true)
{
// Poll on this property if you have to do
// other cleanup before throwing.
if (ct.IsCancellationRequested)
{
// Clean up here, then...
ct.ThrowIfCancellationRequested();
}
}
}, ct); // Pass same token to Task.Run.
// Actually cancel...
tokenSource.Cancel();
// Just continue on this thread, or await with try-catch:
try
{
await task;
}
catch (OperationCanceledException e)
{
Console.WriteLine($"{nameof(OperationCanceledException)}: {e.Message}");
}
finally
{
tokenSource.Dispose();
}
}
// TaskCanceledException : OperationCanceledExceptionTask.ConfigureAwait(continueOnCapturedContext)
By passing false we indicate that we want to continue
the rest of the method on the thread pool instead of the main thread.
This is safe as long as you don’t later do anything that requires the
main thread. Enables some parallelism. Library authors are encouranged
to use false, else can cause deadlocks.
TODO:
await
The async keyword only enables the await
keyword (and manages the method results).
The beginning of an async method is executed just like any other method, i.e., it runs synchronously until it hits an “await” (or throws an exception). The “await” keyword is where things can get asynchronous. Await is like a unary operator: it takes a single argument, an awaitable (an “awaitable” is an asynchronous operation). I like to think of “await” as an “asynchronous wait”. That is to say, the async method pauses until the awaitable is complete (so it waits), but the actual thread is not blocked (so it’s asynchronous).
https://blog.stephencleary.com/2012/02/async-and-await.html
It is the type that is awaitable, not the method returning the type
(so can await a non-async method that returns Task). An awaitable type
is one that includes at least a single instance method called
GetAwaiter() which retrieves an instance of an awaiter
type. https://devblogs.microsoft.com/pfxteam/await-anything/
Used to sync back to the main context. The context is later applied to the remainder of the async method. What is context?
Simple answer:
Complex answer:
The Context can determine whether we resume on the same thread. A UI context will generally resume on the same thread.
Can avoid context using ConfigureAwait(false) which
allows it to run on the threadpool context.
private async Task DownloadFileAsync(string name)
{
var c = await DownloadFileContentsAsync(name).ConfigureAwait(false);
// Because of ConfigureAwait(false), we are not on the original context here.
// Instead, we're running on the thread pool.
// If it was true (default), then it has to re-enter the same context before proceeding.
await WriteToDiskAsync(name, c).ConfigureAwait(false);
// The second call to ConfigureAwait(false) is not *required*, but it is Good Practice.
}
private async void DownloadFileButton_Click(object sender, EventArgs e)
{
// UI context
await DownloadFileAsync(fileNameTextBox.Text);
// Resume on the UI context, we can directly access UI elements.
resultTextBox.Text = "File downloaded";
}| Blocking | Non-Blocking |
|---|---|
| task.Wait | await task |
| task.Result | await task |
| Task.WaitAny | await Task.WhenAny |
| Task.WaitAll | await Task.WhenAll |
| Thread.Sleep | await Task.Delay |
| Task constructor | Task.Run,TaskFactory.StartNew |
https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
If a method is declared async make sure there is an await.
Async method shouldn’t return void, return
Task if nothing to return. Reasons:
Consider using return Task instead of return await:
public async Task<string> AsyncTask()
{
// non-async stuff
return await GetData();
}
public Task<string> JustTask()
{
// non-async stuff
return GetData();
};This avoids the generation of the (somewhat expensive) async state
machine. However, do not wrap return Task inside
try-catch or using block as an exception
thrown by the async method will never be caught, because the task will
be returned right away.
Adding async to function declaration automatically wraps
the return with task [check]. Use await to wait for a task
to complete, and get the unwrapped result.
e.g. await Task.Run(() => DoStuff());
Use .GetAwaiter().GetResult() instead of
.Wait() or .Result: simplifies error-handling
as the latter will return an AggregateException.
Async library methods should consider using
Task.ConfigureAwait(false) to boost performance.
Synchronization context represents a way to return to the original
context of the code: whenever a Task is awaited, it captures current
(thread) synchronization context before awaiting, but this is usually
not needed when writing library code. When
Task.ConfigureAwait(false) is used, the code, if possible,
avoids this context switch and tries to complete in the thread that
completed the task.
Like a multiple-reader multiple-writer thread-safe queue. Lets us communicate between 2 (or more) concurrent operations.
var channel = Channel.CreateBounded<Person>(10);
var tasks = new List<Task>();
// An async job that writes results to the channel
async Task Get(int id)
{
var person = await GetPersonAsync(id);
await channel.Writer.WriteAsync(person);
}
foreach (var id in ids)
tasks.Add(Get(id));
// Wait for the async tasks to complete & close the channel // non-blocking
Task.Run(async () =>
{
await Task.WhenAll(tasks); // blocks
channel.Writer.Complete(); // closes channel
});
// Read person objects from the channel until the channel is closed
// OPTION 1: Using WaitToReadAsync / TryRead
while (await channel.Reader.WaitToReadAsync()) // false if channel is closed
while (channel.Reader.TryRead(out var person)) // false if no items left
Console.WriteLine($"{person.ID}");
// IAsyncEnumerable<Person>
// OPTION 2
await foreach (var person in channel.Reader.ReadAllAsync())
Console.WriteLine($"{person.ID}");// Null-coalescing operator
var x = p ?? valueIfNull;
var x = p ?? throw new ArgumentNullException();
// Null-conditional operators
var x = p?.k; // x will be null if p is null
var x = p?[k];(x == null); // `==` can be overloaded
(x is null);Newer Roslyn compilers make the behavior of the two operators the same when there is no overloaded equality operator.
Local functions double square(double x) { return x; }
Expression Lambda (input-parameters) => expression
Func<int, int> square = x => x * x;
Statement Lambda (input-parameters) => {
Action<string> greet = name => {
string greeting = $"Hello {name}!";
Console.WriteLine(greeting);
};Catch multiple exceptions:
catch (Exception ex) when (ex is ... || ex is ... )
File.WriteAllText(path, text);
using (var file = new StreamWriter(path, append))
file.WriteLine(line);
// Same as new StreamWriter(,append:false,encoding:UTF-8)
using (var streamWriter = File.CreateText(path))
file.WriteLine(line);Span<T>
ref structMemory<T>
structSpan<byte> bytes = stackalloc byte[length];
Span<T> CollectionsMarshal.AsSpan<T>(List<T> list);
Span<TTo> MemoryMarshal.Cast<TFrom,TTo> (Span<TFrom> span);
var xs = new int[] {1,2,3};
var mem = new Memory<int>(xs);
xs.Slice(1,2);
var s = xs.Span();
s[0], s[2] // can't index into Memory yet// create a SIMD vector from an array, starting at index
// vector will have length Vector.Count (depends on machine)
new Vector(T[] values, int index);
GCSettings.LatencyMode enum can instruct the GC to prioritize latency.
var parameters = new object[] {};
var instance = Activator.CreateInstance(typeof(T), parameters);
var assembly = Assembly.LoadFrom(assemblyPath);
var methods = assembly.GetExportedTypes()
.SelectMany(t => t.GetMethods());Keywords:
unsafe: denotes an unsafe contextfixed: instructs the GC to not move a movable
(i.e. managed reference) variable.
stackalloc:
Passing pointers between methods can cause undefined behavior.
Function pointers can be defined using delegate*.
unsafe void Fun()
{
double[] arr = { 0.0, 1 };
string str = "Hello";
// can't point to a reference or to a struct that contains references
// unless you use fixed
fixed (double* p = arr) { /*...*/ }
// is the same as
fixed (double* p = &arr[0]) { /*...*/ }
fixed (char* p = str) { /*...*/ }
}
unsafe
{
int i = 5;
int* p = &p;
*p *= *p; // squares
}
internal unsafe struct Buffer
{
// stored on the type, i.e. not a reference
public fixed char fixedBuffer[8];
}
[Test]
[TestCase(6, 13)]
[TestCase(3, 10)]Type aliases
using Alias = Namespace.Target;This can be used to keep consistent names in source when using an external lib, using shorter more familiar names etc.
A dynamic templating engine that produces C# source code at compile-time. Provides access to the syntax tree which enables compile-time access to information about the code such as members of a class, names and types of members etc. Can provide compile-time reflection which can allow much higher performance including ahead-of-time compilation and linking support.
// TODO: check this
C# can only call C functions with a certain calling convention (I think stdcall?) C++ has a different ABI than C (due to name mangling). In short, C is the common denominator between C# and C++ so you need to have a layer of abstraction between C# and C++ in C.
You basically have to go C++ -> C -> C#, so you need:
using System.Text.Json;
var options = new JsonSerializerOptions { WriteIndented = true };
string jsonString = JsonSerializer.Serialize(dto, options);
Dto? dto = JsonSerializer.Deserialize<Dto>(jsonString);How to de/serialize using custom JsonConverter with a manual step
while still getting automatic de/serialization otherwise. Also, a nice
showcase of C#’s verison of defer (a la go, zig, etc).
Performance optimisation strategies of the last resort https://stackoverflow.com/q/926266/3142827
What and where are the stack and heap? https://stackoverflow.com/q/79923/3142827
Storing variables on the Stack vs Heap (value vs ref types) https://stackoverflow.com/a/1114152/3142827