Optional Parameters In Constructor Vs Init Properties

Posted by : on

Category : C#

Introduction

Object initialization is a crucial aspect of any OOP language. When constructing an object, we define its initial state. To maintain minimal states, we utilize language mechanisms that prevent changes after initialization.

The fewer states an object can transition to during runtime, the easier it is to understand and debug the code. This is why immutability is significant in C#. C# offers various ways to initialize an object so that its fields and properties are read-only.

Programmers often design types so that objects created from these types have default values representing the most generic case. This allows users to create objects without specifying all the necessary values unless the default ones are unsuitable for their use case.

Traditionally, initializing these immutable values involves using the object’s constructor. However, starting from C# 9, we have the option to use init-only properties. These properties can only be assigned values during object initialization, similar to how read-only fields are initialized via the constructor.

In this post, I will provide a brief introduction to default parameters in constructors and init properties. I will also discuss scenarios where using init-only properties with default values is preferable to using default parameters.

What Are Default Parameters

Default parameters in constructors work the same way as default parameters in methods. They are assigned a value that will be used as the default if the parameter is not provided during construction. Default parameters are always placed after any other parameters the constructor may have.

An example:

public class Animal
{
   public string Name { get; }
   public bool IsMammal { get; }
   public int NoOgLegs { get; } 
   
   public Animal(string name, bool isMammal = true, int noOgLegs = 4)
   {
      Name = name;
      NoOgLegs = noOgLegs;
      IsMammal = isMammal;
   }
}

Here the only required parameter is the name, the isMammal and noOfLegs are default parameters and if they are not used during the object’s construction they will retain their default values. Here are some ways that we can create a new object of type Animal :

Animal dog = new("Pluto the dog");

Animal duck = new("Donald the Duck", false, 2);

Animal squirrel = new("Chip the Squirrel", noOgLegs:2);

As we can see, we can change any default parameter, we can even omit any default parameters before the parameter we want to change simply by providing the name of the argument, as in the squirrel where we omitted the isMammal and only changed the value of the noOgLegs.

What Are Init Properties

The init keyword in properties replaces the set keyword when we want a property to be writable only during the object’s construction. We can provide default values for our properties when appropriate. For properties without a sensible default value that must be provided during construction, such as the name parameter mentioned earlier, we can use the required keyword (available from C# 11).

Here’s how the previous example can be written using the Init and required properties:

public class InitAnimal
{
   public required string Name { get; init; }
   public bool IsMammal { get; init; } = true;
   public int NoOfLegs { get; init; } = 4;
}

And here’s how we can create the same objects as before:

InitAnimal initDog = new() { Name = "Pluto the dog" };

InitAnimal initDuck = new() { Name = "Donald the Duck", IsMammal = false, NoOfLegs = 2 };

InitAnimal initSquirrel = new() { Name = "Chip the Squirrel", NoOfLegs = 2 };

What is happening here, is that a temporary object is created where every property is assigned and when all properties are assigned then our object identifier gets the reference of the temporary object. For example, for the initDog something like this happens:

InitAnimal temp = new();
temp.Name = "Pluto the dog";
InitAnimal initDog = temp;

The reason for this temporary object, is that in the case an exception is thrown during the object’s construction, we won’t have our initDog variable pointing to a half-initialized InitAnimal object.

Combining Constructor Parameters And Init Properties

We can combine constructors and Init properties. Here’s an example:

public class CombinedAnimal
{
   public required string Name { get; init; }
   public bool IsMammal { get; init; } = true;
   public int NoOfLegs { get; init; } = 4;

   public CombinedAnimal() { }

   [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
   public CombinedAnimal(string name) => Name = name;
}

Here we can create the dog in the previous examples, with two different ways:

CombinedAnimal combinedDog = new("Pluto the dog");

CombinedAnimal combinedDog2 = new(){ Name = "Pluto the dog" };

The SetsRequiredMembers attribute tells the compiler, that any required property is set in the constructor. Be careful, because this is not checked, it’s your responsibility to actually have code that sets any required property.

Finally, notice the empty constructor. Without that, the initialization of the combinedDog2 wouldn’t be possible.

When Init Properties Are Better Suited For Object Initialization

Historically, the initialization of an object via its property members was not preferred, mainly for two reasons: It introduced a Temporal coupling and there was no way to ensure the immutability of these properties.

Init properties address these problems. When we use a property initializer with our automatic properties, there are certain cases where Init properties are better suited for object initialization than default parameters in the constructor.

Too Many Default Parameters

When we have too many default parameters, the signatures of our constructors become more complicated. Although this is not a major issue, it can make the code more tedious and difficult for readers to understand. Having only the parameters that require a value in the constructor, and using Init properties with default values for the rest, makes the code easier to understand.

Nondestructive Mutation

A bigger problem is nondestructive mutation, which is the process of creating a new object from an immutable object with most of the same data but some changes. This is most common in Records. While this can be solved without Init properties, it requires significantly more code to create a modified copy. With Init properties, it is much simpler:

Point first = new Point() { X = 1, Y = 1 };
Point second = first with { X = 2 };

record Point
{
   public int X { get; init; } 
   public int Y { get; init; }
}

The Point can still be written as:

record Point (int X, int Y);

and created:

Point first = new Point(1, 1);
Point second = first with { X = 2 };

But in reality, the compiler behind the scenes, created Init properties for X and Y, that’s why we can create the second with nondestructive mutation.

Binary Compatibility

A significant problem with default parameters is backward compatibility. If we need to add a default parameter to the constructor of a type in a library we have created, any code that uses our library will fail because the binary compatibility is broken. Default parameters are resolved at compile time. When we have the library:

namespace SquareLibrary;

public class Square
{
   public string Name { get; }

   public Square(string name)
   {
      Name = name;
   }
}

And the code that uses it:

using SquareLibrary;

Square square = new Square("Amazing square");

Console.WriteLine(square.Name);

If we add a default parameter to our square class:

namespace SquareLibrary;

public class Square
{
   public string Name { get; }
   public float SideLength { get; }

   public Square(string name, float sideLength = 1)
   {
      Name = name;
      SideLength = sideLength;
   }
}

Then the existing code that uses this assembly will fail because we have broken the binary compatibility by changing the constructor’s signature. Even though the parameter sideLength has a default value, we need to recompile our code against the new assembly because this parameter is embedded in our code at compile time, even if we don’t explicitly provide it. Instead, with Init properties, we can make the change like this:

namespace SquareLibrary;

public class Square
{
   public string Name { get; }
   public float SideLength { get; init; } = 1;

   public Square(string name)
   {
      Name = name;
   }
}

And no exception is thrown, because our constructor’s signature hasn’t changed.

Source Compatibility

Finally, with default parameters in the constructor, we can break the source compatibility. Consider the following library:

namespace SquareLibrary;

public class Square
{
   public float SideLength { get; }

   public Square(float sideLength = 1)
   {
      SideLength = sideLength;
   }
}

And the code that uses it:

using SquareLibrary;

Square square = new Square();

Console.WriteLine(square.SideLength);

In the console we will see the number 1 printed.

Now suppose that we update the library to have a default sideLength value of 2.

The code that uses the library, even if it uses the new assembly will still print 1 on the screen. The reason is that our code is compiled like we were calling Square square = new Square(1); and unless we recompile our code with the new assembly, the change in the default value won’t be seen and the old value will be used.

This is the same problem with the one I describe in my post Prefer overloading than default parameters

If this problem doesn’t seem important enough consider the following scenario:

Someone creates the open source library SquareLibrary as before.

Then two people, create their own libraries that use it. Library A, calculates the length of a number of squares, something like this:

namespace SquareLengthLibrary;

using SquareLibrary;

public class SquareLength
{
   private readonly Square _square = new();
   public float LengthOfSquares(int amount) => amount * _square.SideLength;
}

Library B, calculates the area of a number of squares, like this:

namespace AreaOfSquaresLibrary;

using SquareLibrary;

public class SquareArea
{
   private readonly Square _square = new();
   public float CalculateSquaresArea(int amount) => amount * _square.SideLength * _square.SideLength;
}

Then a user creates an app that uses both A and B libraries:

using SquareLengthLibrary;
using AreaOfSquaresLibrary;

SquareLength squareLength = new();
SquareArea squaresArea = new();

int numberOfSquares = 5;

Console.WriteLine(squareLength.LengthOfSquares(numberOfSquares));
Console.WriteLine(squaresArea.CalculateSquaresArea(numberOfSquares));

The result will be 5 and 5.

Then SquareLibrary changes the default value of sideLength to 2. The owner of Library B, which calculates the area of shapes, decides to add an unrelated feature (e.g., calculating the area of circles), so they recompile the library and distribute the new version.

Now, the default value of 2 will be embedded in the second library but not in the first. The result of the user’s program will be 5 and 20.

This is a hard bug to find. Without any changes to the code, the program will behave differently. By examining its dependencies, the user will see that only Library B was updated to a new version, so the bug must be in that version.

The user contacts the author of Library B, but the author insists that nothing on their side could cause the issue, as their changes were unrelated to the square area calculation. They only added a new class to calculate the area of a circle.

In reality, the only way to fix the bug and get the correct result (5 squares with a length of 10 and an area of 20) is for the user to contact the author of Library A and ask them to recompile against the new SquareLibrary. But this is only possible if the user has thoroughly investigated and knows all the libraries used by the libraries their program depends on.

All of this is essentially the fault of the author of SquareLibrary. If, instead of a default parameter in the constructor, they had used an init property with a default value, the value wouldn’t have been embedded in Libraries A and B at compile time. Instead, the libraries would read the property’s value at runtime, and no recompilation would be needed. Any code using those libraries would also use the new default property value.

Conclusion

This is it about the differences between using optional parameters in the constructor and init properties.

Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


About Giannis Akritidis

Hi, I am Giannis Akritidis. Programmer and Unity developer.

Follow @meredoth
Follow me: