Modernize Your C# Code - Part 3

Want to modernize your C# codebase? Let's continue with values.

Modernizing C# Code

Table of Contents

Introduction

In the recent years C# has grown from a language with exactly one feature to solve a problem to a language with many potential (language) solutions for a single problem. This is both, good and bad. Good, because it gives us as developers freedom and power (without compromising backwards compatibility) and bad due to the cognitive load that is connected with making decisions.

In this series we want to explore what options exist and where these options differ. Surely, some may have advantages and disadvantages under certain conditions. We will explore these scenarios and come up with a guide to make our life easier when renovating existing projects.

This is part III of the series. You can find part I as well as part II on the CodeProject.

Background

In the past I've written many articles particularly targetted at the C# language. I've written introduction series, advanced guides, articles on specific topics like async / await or upcoming features. In this article series I want to combine all the previous themes in one coherent fashion.

I feel its important to discuss where new language features shine and where the old - let's call them established - ones are still preferred. I may not always be right (especially, since some of my points will surely be more subjective / a matter of taste). As usual leaving a comment for discussion would be appreciated!

Let's start off with some historic context.

What do I mean with Values?

This article won't be about value types (struct) vs reference types (class) even though this distinction will play a crucial role. We will see that structs are desired for various reasons and how they are naturally introduced (already at the language level) to improve performance.

Instead, in this article we generally focus on used primitives - starting with a static readonly vs. const debate and then discussing string values; especially with the new interpolated strings.

Another important pillar of this article will be analysis of tuples (standard tuples vs value tuples, "anonymous" vs named tuples). As C# is still taking some inspirations from F# and other languages to introduce more functional concepts. These concepts usually come with value type data transport in form of records (pretty much immutable DTOs).

There's much to look at - so let's start with some basics before going into more details.

Is Const Readonly?

C# contains two ways of not allowing a variable to be reassigned. We have const and readonly. There are, however, very important differences for using the two.

const is a compile-time mechanism that is transported via metadata only. This means that it has no runtime effect - a const declared primitive is essentially placed at its use. There is no load instruction. Its pretty much an inline-replacement constant.

This is also where the area of use comes into play. Since there is no load the exact value is determined at compile-time only, pretty much requiring all libraries that used the constant to be recompiled in case of a change. Therefore, const should never be exposed outside of a library, except in very rare cases (e.g., natural constants such as p or e).

The good thing is that const works on multiple levels, e.g., in a class or a function. Due to the inline-replacement nature only a few primitives (e.g., strings, characters, numbers, booleans) are allowed.

readonly on the other side only protects the variable from reassignment. It will be properly referenced and results in a load instruction. Due to this alone the possible values are not constraint to some primitives. However, note that readonly is not the same as immutable. In fact, the mutability is exclusively determined by the stored value. If the value is a reference type (class) that allows mutation of its fields there is nothing we can do with readonly.

Also the area of use is different to const. In this case we are limited to fields, e.g., members of a struct or class.

Let's recap when we would use const and when readonly is preferred.

readonly const
  • General "constants"
  • Sharing values (e.g., strings) across assemblies
  • Fields in types to simplify development
  • Real constants (p, e, ...)
  • Inside a library (internal) / class (private) for high-performant re-use

The Modern Way

A value is a value. That still (and will always) hold. Nevertheless, writing values and using values efficiently is certainly in the domain of the programming language. As such its natural to see some enhancment in these areas.

In the recent years C# was driven more into the functional area. Due to its imperative nature we will never see a purely functional version of C# (but hey, there's F# if you want to be more radical), however, that does not exclude that we can obtain some of the good sides of functional programming.

Nevertheless, most of the improvements we can do here are completely unrelated of functional programming and the patterns used therein.

Let's start with plain numbers to see where this is going.

Number Literals

Standard numbers are really the most boring, yet interesting topic. The topic is boring, because at its core a number is just close to the most primitive and elementary information. The thing in front of you is called a "computer" for a reason. Numbers are the bread and butter. What usually makes numbers interesting are the introduced abstractions or specific algorithms where they are applied.

Suffixes

Still, a number is not really a number from the perspective of the compiler. It is first seen as a sequence of bytes (characters) correctly identified to yield a special token - a number literal. This token already provides information about the type of the number. Do we deal with an integer? Signed or unsigned? Is it a floating point number? Fixed precision? What's the size or specific type of the number in the machine?

While integers and floating-point numbers can be easily distinguished via the ., all other raised questions are difficult to answer. For this reason C# introduced special suffixes. For instance, 2.1f is different to 2.1 with respect to the used primitive type. The former being a Single, while the latter is a Double.

Personally, I like to use var whenever I can - as such I would always use the correct literal. So instead of writing

double sample = 2;

I would always advise to write

double sample = 2.0;

Following this approach we end up at the right type inference without much trouble.

var sample = 2.0;

The standard suffixes such as m (fixed-precision floating point), d (double-precision floating point, equivalent to dotted numbers without any suffix), f (single-precision floating point), u (unsigned in integer), or l ("larger" integers) are all known fairly well. u and l may be combined as well (e.g., 56ul). The casing does not matter, such that depending on the font a capital L may be more readible.

So what's the reason for specifying the right suffix immediately? Is it really just to satisfy my personal habit of using VIP (var if possible) style? I think there's more to it. The compiler directly "sees" how to use the number literal token. This has a huge impact as some numbers cannot be represented by non-fixed precision floating point numbers. The best example is 0.1. Usually, the rounding error is quite well hidden in serializations (calling ToString) here, but once we perform operations such as 0.1 + 0.1 + 0.1 the error grows large enough to be no longer hidden in the output.

Using 0.1m instead already puts the found token in a decimal at runtime. As such the precision is fixed and no information is lost in the process (e.g., by using a cast - as there is no way to regain lost information reliably). Especially, when dealing with numbers where fractions matter a lot (e.g., everything related to currency) we should exclusively use decimal instances.

Useful for Avoid for
  • Usage with VIP style
  • Avoid unnecessary casts
  • Expressiveness

Separators

Most number literals should be fairly "small" or "simple", e.g., 2.5, 0, or 12. Nevertheless, especially when dealing with longer binary or hexadecimal integers (e.g., 0xfabc1234) readability may suffer. The classic pair grouping of hexadecimal numbers (or 3 digits for decimal numbers)

To increase readability modern C# added number literal separators in form of an underscore (_).

private const Int32 AwesomeVideoMinimumViewers = 4_200_000;

private const Int32 BannedPrefix = 0x_1F_AB_20_FF;
Useful for Avoid for
  • Making larger numbers more readable
  • Encoding numbers differently

Unfortunately, no literals for BigInt or Complex exist yet. Complex is still created best via two double numbers (real and imaginary part), while a BigInt is most likely best created from parsing a string at runtime...

Simple Strings

The string is the most complex of the simplest data types. It is by far the most used data type - there are whole languages just built around string - and requires a lot of internal tricks to be used efficiently. Features such as string interning, hashing, and pinning are crucial to well functioning string implementations. Luckily, in .NET we take all these things for granted as the framework does the heavy lifting for us.

The .NET string allocation will always require 2 bytes per character. This is the UTF-16 model. A character may be a surrogate thus requiring another character (in other words: another 2 bytes) to represent a printable character.

The allocation also uses some tricks to, e.g., speed up lookups. An object header is used to store such meta information.

Is ASCII Flag in .NET String Allocation

The string literal itself in C# allows two switches, which may be combined. One is a "verbatim string" - prefixed with @:

var myString = @"This
is a very long strong
that may contain even ""quotes"" and other things if properly escaped...
See you later!";

The other switch is for the "interpolated string", which will be discussed in the next section.

From the MSIL point of view a verbatim string literal and the standard string literal have exactly the same behavior. The difference lies only in the treatment by the C# compiler.

Let's look at a sample C# code to confirm this:

var foo = @"foo
bar";
var FOO = foo.ToUpper();

The generated MSIL contains a standard MSIL string literal. In MSIL there are no switches for the string literal.

IL_0001:  ldstr       "foo\r\nbar"
IL_0006:  stloc.0
IL_0007:  ldloc.0
IL_0008:  callvirt    System.String.ToUpper

In the verbatim literal no escape sequences work. As such the literal string is perfectly suited for a Windows path:

var myPathDoesNotWork = "C:\Foo\Bar"; //Ouch, has to be C:\\Foo\\Bar - makes copy / paste difficult ...
var myPathWorks = @"C:\Foo\Bar";

Since double quotes also require an escape sequence a new method for writing double quotes in a string had to be introduced. In verbatim strings two double-quotes ("double double quotes" or "quadruple quotes") represent a single double quote.

var withDoubleQuotes = @"This is ""madness"", isn't it?";

Mostly, we will not use verbatim literals even though generally they would potentially make the most sense (support for copy / paste of standard text is just simpler due to support for newline literals instead of escape sequences).

Verbatim Standard
  • Not many / no double quotes
  • Many multi-lines
  • Direct copy paste support
  • Paths
  • Write special characters (escape sequence)
  • Use just a single-line
  • Many (escaped) double quotes

In general the string type is ideal for an UTF-16 (i.e., fixed encoding) represented string fragment that does not require any compile-time replacements and represents a simple sequence of characters.

We should avoid the String type for multiple (potentially unknown many) concatinations. This will create a lot of garbage and should be replaced with a specialized type (StringBuilder) for the job.

Interpolated Strings

Besides the @ there is another potential switch for a string literal: The $ switches into an interpolated string mode. Both switches can also play together:

var foo = $@"foo
bar{'!'}";

In case of an interpolated string the compiler makes a closer evaluation. If no replacements (given in the curly braces {}) are specified a simple string will be produced.

With replacements available each replacement will be evaluated in put into a good old formatting string. As such the compiler generates above:

var arg0 = '!';
var foo = string.Format(@"foo
bar{0}", arg0);

Naturally, the compiler could do more optimizations in the described case (after all we have a constant character), however, the compiler was built not for the straight forward case, but for the common case:

void Example(string input)
{
    var foo = $"There is the input: {input}!";
}

The generated MSIL reveals that there is no difference to the previously generated code - making it generally usable independent of the inserted expression (constant or not).

Example:
IL_0000:  nop         
IL_0001:  ldstr       "There is the input: {0}!"
IL_0006:  ldarg.1     
IL_0007:  call        System.String.Format
IL_000C:  stloc.0     

So far this is rather uninteresting. A simple wrapper around the Format function. However, there is another option if we do not opt for string (or var for that matter). If we target FormattableString or an IFormattable the C# compiler we generate a different code:

void Example(string input)
{
    FormattableString foo = $"There is the input: {input}!";
}

In this case the generated MSIL looks quite a bit different:

Example:
IL_0000:  nop         
IL_0001:  ldstr       "There is the input: {0}!"
IL_0006:  ldc.i4.1    
IL_0007:  newarr      System.Object
IL_000C:  dup         
IL_000D:  ldc.i4.0    
IL_000E:  ldarg.1     
IL_000F:  stelem.ref  
IL_0010:  call        System.Runtime.CompilerServices.FormattableStringFactory.Create
IL_0015:  stloc.0     

Note that the arguments need to be delivered in form of an (object) array. The stelem.ref code will replace the 0 index element with the element on the stack.

This data structure can be used such that the different values used in the formatting string are custom formatted.

Take for instance the following code (which assumes there is a single argument - it would make more sense to iterate over the ArgumentCount) placing the first argument as uppercase.

void Example(string input)
{
    var foo = CustomFormatter($"There is the input: {input}!");
}

string CustomFormatter(FormattableString str)
{
    var arg = str.GetArgument(0).ToString().ToUpper();
    return string.Format(str.Format, arg);
}

Otherwise, the given formatter already can be used together with formatting literals quite easily (and exactly the same as string.Format calls).

Likewise, we can use the ToString of an IFormattable with a CultureInfo or general IFormatProvider to get a culture specific serialization.

var speedOfLight = 299792.458;
FormattableString message = $"The speed of light is {speedOfLight:N3} km/s.";
var specificCulture = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
var germanMessage = message.ToString(specificCulture);
// germanMessage is "The speed of light is 299.792,458 km/s."

Interpolated strings are a great way to preserve readability while being at least as powerful as beforehand with the plain String.Format function.

Since the expression replacement inside the curly braces denotes a special meaning to the colon ternary expressions such as a ? b : c should be avoided in interpolated strings. Instead, such expressions should be evaluated beforehand and refer only to a simple variable.

Important: The good old String.Concat() calls for multiple string concatinations should not be necessarily replaced, e.g.,

// don't, but better than a + b + c + d + e + f
var str = $"{a}{b}{c}{d}{e}{f}";
// better ...
var str = string.Concat(a, b, c, d, e, f);
// best - maybe ...
var str = new StringBuilder()
    .Append(a)
    .Append(b)
    .Append(c)
    .Append(d)
    .Append(e)
    .Append(f)
    .ToString()

where the StringBuilder is the last resort for - either an unknown amount of additions, or once the String.Concat(...) is essentially equivalent to String.Join(String.Empty, new String [] { ... }).

Useful for Avoid for
  • Expression replacements at compile-time
  • Flexible serializations
  • Simple strings that contain curly brackets
  • Using interpolated strings for concat replacement

String View

Sometimes all we want is to navigate within a part of a string. Formerly, there have been two approaches to solve this problem:

  • Take the desired sub string as a new string using the Substring method - this will allocate a new string
  • Create an intermediate data structure or local variables to represent the current index - this allocates the temporary variables

The approach with the new string can be very memory intensive and thus unwanted. The approach with the local variables is definitely quite efficient, however, requires changes to the algorithms and may be quite complicated to correctly implement.

Since this is a problem that especially arises in parsers of any kind the C# / Roslyn team thought about a potential solution. The result is a new data type called Span<T>, which can be used for arbitrary views concerning memory.

Essentially, this type gives us the desired snapshot, but in the most efficient manner possible. Even better, this simple value type that allows us to work with any kind of contiguous memory:

  • Unmanaged memory buffers
  • Arrays and subarrays
  • Strings and substrings

There are two prerequisites to start working with Span<T>. We need to install the latest System.Memory NuGet package and set the language version to C# 7.2 (or a more recent version).

Since a memory view requires ref return values only .NET Core 2+ supports this feature natively (i.e., expect a performance difference between .NET Core 2+ and some other platform with an older GC and .NET runtime).

The magic behind a Span<T> is that we can actually have direct references returned. With ref returns this looks as follows:

ref T this[int index] => ref ((ref reference + byteOffset) + index * sizeOf(T));

The expression is quite similar to the way we learned to iterate an array of memory in good old C.

Coming back to the topics of having string views. Let's consider the old example:

public static string MySubstring(this string text, int startIndex, int length)
{
    var result = new string(length);
    Memory.Copy(source: text, destination: result, startIndex, length);        
    return result;
}

Of course, this is not real code. This is just to illustrate the idea. The real code would look more like:

public static string MySubstring(this string text, int startIndex, int length)
{
    var result = new char[length];
    Array.Copy(text.ToCharArray(), startIndex, result, 0, length);
    return new string(result);
}

This is even worse. Instead of just 1 allocation (new string) we have two more allocations (new char[], ToCharArray()). Don't do that at home!

The MSIL is also not looking nice. In particular, we have callvirt and a constructor mixed in.

IL_0000:  nop         
IL_0001:  ldarg.2     
IL_0002:  newarr      System.Char
IL_0007:  stloc.0     // result
IL_0008:  ldarg.0     
IL_0009:  callvirt    System.String.ToCharArray
IL_000E:  ldarg.1     
IL_000F:  ldloc.0     // result
IL_0010:  ldc.i4.0    
IL_0011:  ldarg.2     
IL_0012:  call        System.Array.Copy
IL_0017:  nop         
IL_0018:  ldloc.0     // result
IL_0019:  newobj      System.String..ctor
IL_001E:  stloc.1     
IL_001F:  br.s        IL_0021
IL_0021:  ldloc.1     
IL_0022:  ret         

So what do we sacrifice by using str.AsSpan().Slice() instead of str.Substring()? Flexibility. Since the Span<T> is keeping a direct reference we are not allowed to put it on the heap. Therefore, certain rules exist:

  • No boxing
  • No generic types
  • It cannot be a field in a class or non-ref struct type
  • No usage inside async methods
  • It must not implement any existing interface

That a pretty long list! Thus in the end we can only use it directly or indirectly as arguments.

Useful for Avoid for
  • Avoid temporary memory allocations
  • Sequential parsers
  • Unmanaged (stack) memory
  • Asynchronous parsing
  • Custom / source independent lifetime

Memory View

While Span<T> has been introduced together with the stackalloc keyword, it also allows direct usage of any T[] array type.

Span<byte> stackMemory = stackalloc byte[256];

As already discussed this is bound heavily to the stack lifetime and thus has severe limitations in the usage.

Still, having also the possibility to use .NET heap placed arrays via this mechanism gives us the idea - why is there no type that has a similar purpose but does not come with these limitations?

Let's first see the usage with .NET standard arrays:

Span<char> array = new [] { 'h', 'e', 'l', 'l', 'o' };

Now with the new Memory<T> type we can actually pass around an eventual Span<T> that transforms when needed.

The idea is that the memory is passed around and stored on the heap, but eventually we want to the performance and take the view (span) for a well-defined short amount of time.

The API is quite simple. We can hand over to, e.g., to the constructor of ReadOnlyMemory<T> a .NET array together with the "view" (start and length) of this memory region. With the Span property we can obtain an intermediate representation that directly references the view without any additional allocation.

void StartParsing(byte[] buffer)
{
    var memory = new ReadOnlyMemory<byte>(buffer, start, length);
    ParseBlock(memory);
}

void ParseBlock(ReadOnlyMemory<byte> memory)
{
    ReadOnlySpan<byte> slice = memory.Span;
    //...
}

As a result even managed memory views are quite easily possible and eventually better performing than previously.

Useful for Avoid for
  • Keeping on the heap
  • Managed (heap) memory
  • Asynchronous parsing
  • Sequential parsers

Simple Tuples

A common problem in C# is that there is (or was) no way to return multiple values. To circumvent this problem in the first place the C# team came up with out parameters, which provide some additional sugar on top of ref parameters.

In a nutshell, once a parameter is defined as an out parameter it needs to be assigned within the function. For convenience, the C# team added the ability to declare such variables inline.

This is the code we can write in general:

void Test()
{
    var result1 = default(int);
    var result2 = ReturnMultiple(out result1);
}

bool ReturnMultiple(out int result)
{
    result = 0;
    return true;
}

And a more modern version looks like:

void Test()
{
    var result2 = ReturnMultiple(out var result1);
}

While the MSIL for the pre-initialization looks as follows, the MSIL for the more modern one skips two instructions:

// pre-initialization
IL_0000:  nop         
IL_0001:  ldc.i4.0    
IL_0002:  stloc.0     // result1
IL_0003:  ldarg.0     
IL_0004:  ldloca.s    00 // result1
IL_0006:  call        ReturnMultiple
IL_000B:  stloc.1     // result2
IL_000C:  ret         

// modern version
IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  ldloca.s    01 // result1
IL_0004:  call        ReturnMultiple
IL_0009:  stloc.0     // result2
IL_000A:  ret         

Granted, out variables can definitely help, but they bring some other drawbacks and do not improve readability. For this, we can also introduce intermediate data types, however, once we use the pattern of returning multiple values a lot the sea of intermediate types is increasing even more than necessary.

One way to solve this is via "generic" intermediate data types - a tuple - to contain some values. In .NET we have the Tuple type in its various forms (e.g., Tuple<T1, T2>, Tuple<T1, T2, T3>, ...). Wee can think of this as the data carrier equivalent to Func with its generic variants.

The problem stated above could thus be changed to:

void Test()
{
    var result = ReturnMultiple();
    var result1 = result.Item1;
    var result2 = result.Item2;
}

Tuple<bool, int> ReturnMultiple() => Tuple.Create(true, 0);

Quite logically, the code in the Test method above is more complicated from an MSIL point of view:

IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  call        ReturnMultiple
IL_0007:  stloc.0     // result
IL_0008:  ldloc.0     // result
IL_0009:  callvirt    System.Tuple<System.Boolean,System.Int32>.get_Item1
IL_000E:  stloc.1     // result1
IL_000F:  ldloc.0     // result
IL_0010:  callvirt    System.Tuple<System.Boolean,System.Int32>.get_Item2
IL_0015:  stloc.2     // result2
IL_0016:  ret       

However, the code in the ReturnMultiple method looks a bit simpler. Still, we pay the cost of allocating a Tuple object. Object allocation is still one of things to minimize.

The other obvious drawback is the strange naming. What is Item1, what is Item2? If the tuple is only used in the callee there is not much trouble, however, once we pass on the tuple we come into issues quite quickly. In th given case the different types help a bit, but in general two or more items will have the same type.

Useful for Avoid for
  • Transporting back many items
  • Potentially skipping one or multiple of the return values in the callee
  • Less than four items
  • Passing on the result

Value Tuples

One of the downsides in the former approach was the object allocation. Thus a value type may be useful. This is why ValueTuple has been introduced.

With this new type we can just change the last example to become:

void Test()
{
    var result = ReturnMultiple();
    var result1 = result.Item1;
    var result2 = result.Item2;
}

ValueTuple<bool, int> ReturnMultiple() => ValueTuple.Create(true, 0);

This data type is so interesting that C# brought in some sugar for specifying, creating, and using it. Let's start with the specification:

(bool, int) ReturnMultiple() => ValueTuple.Create(true, 0);

Nice, so we can easily specify multiple return valus (or a ValueType - to be more specific) by using parentheses! Next is the creation. Can we do better here?

(bool, int) ReturnMultiple() => (true, 0);

Wow, feels almost functional! Great, so now how can we use this more elegantly?

void Test()
{
    var (result1, result2) = ReturnMultiple();
}

(bool, int) ReturnMultiple() => (true, 0);

Almost too easy, right? This syntax is called destructuring. I guess we will see much more of this in upcoming versions of C# (in other variants and use cases, of course).

And we are done! Still, the naming is unfortunate for direct, i.e., non-destructered use cases.

Useful for Avoid for
  • Transporting back many items
  • Potentially skipping one or multiple of the return values in the callee
  • Directly destructuring the items
  • More than five items
  • Passing on the result

Named Tuples

So far, the naming of the different items has been the major drawback to be tackled. For this the C# language design team invited "named tuples", which give us a way to declare (fake) names on the ValueType. These names are resolved at compile-time only.

The name of a tuple item can be added anywhere in the tuple specification, e.g., if we only want to name the first item we are free to do so:

(bool success, int) ReturnMultiple() => (true, 0);

The standard "type on the LHS" is preserved here. In C# we still (like in other C languages) follow "type identifier" (e.g., compare to TypeScript which uses "identifier: type" and is RHS based).

The naming cannot be used on the value creation, which must be in order. Furthermore, it has no effect on destructuring.

void Test()
{
    var (foo, bar) = ReturnMultiple();
}

(bool Success, int Value) ReturnMultiple() => (true, 0);

Such a named tuple can be passed on arbitrarly. Consider this example:

void Test()
{
    var result = ReturnMultiple();
    var (foo, bar) = result;
    AnotherFunction(result);
}

void AnotherFunction((bool Success, int Value) tuple)
{
    // ...
}

This sugar (on top of the usual ValueTuple tuple) provides performance and convenience, which makes is an ideal replacement candidate where previously only out could help.

Useful for Avoid for
  • Always when a value tuple shines!
  • Passing on the result
  • More than five items

Outlook

In the next (and most likely last) part of this series we will take on asynchronous code and special code constructs such as pattern matching or nullable types.

With respect to values the evolution of C# seems to not have finished yet. Real records and other primitives found in languages like F# are so useful they are hard to be missed. What I am personally missing are primitives to deal with these views (Span<T>) already on the language level.

Furthermore, a literal (suffix) for BigInt would be appreciated (maybe b?). Same goes for Complex, which would naturally go for i, such that 5i is equivalent to new Complex(0, 5.0).

Conclusion

The evolution of C# has not stopped at the used values. We saw that C# gives us some more advanced techniques to gain flexibility without performance impact. The help from the framework with respect to slices comes also in quite handy.

The previous way of formatting strings with String.Format should not longer be used. Interpolated strings offer quite some nice advantages. Returning multiple values has never been easier. What patterns emerge from this is still to be determined. Together with local functions and the evolution in the properties space the C# language feels more than vitalized already.

Points of Interest

I always showed the non-optimized MSIL code. Once MSIL code gets optimized (or is even running) it may look a little bit different. Here, actually observed differences between the different methods may actually vanish. Nevertheless, as we focused on developer flexibility and efficiency in this article (instead of application performance) all recommendations still hold.

If you spot something interesting in another mode (e.g., release mode, x86, ...) then write a comment. Any additional insight is always appreciated!

History

  • v1.0.0 | Initial Release | 17.05.2019
  • v1.1.0 | Added table of contents | 18.05.2019
  • v1.1.1 | Corrected typo in code | 22.05.2019
  • v1.1.2 | Corrected spelling | 23.05.2019
  • v1.1.3 | Fixed typo in code | 25.05.2019
Created . Last updated .

References

Sharing is caring!