In this article, we will learn about the categories of C# data types. We will deep dive into the differences between value types and reference types, what are they and what’s the behavior of each type when instantiated, compared, or assigned.

It’s important to understand that C# is a strongly-typed language. Each variable has a specific type and each type can either be a value type or a reference type.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Value Types

Value types variables directly contain their data and all their types are defined using the struct keyword which makes them implicitly sealed and inherit from System.ValueType. .NET comes with several built-in value types:

  • Numeric types: int, long, float, double, decimal, short, etc
  • bool and char types
  • Date types: DateTime, DateOnly, TimeOnly, TimeSpan

Apart from the built-in value types, we can also define custom value types using the struct keyword, they can have both value types or reference types as fields or properties:

public struct ValueTypeRectangle
{
    public int Length { get; set; }
    public int Width { get; set; }
    public Shape MyShape { get; set; }

    public int Area()
    {
        int area = Length * Width;
        Console.WriteLine($"length = {Length}");
        Console.WriteLine($"width = {Width}");
        Console.WriteLine($"shape = {MyShape.Name}");
        Console.WriteLine($"area = {area}");

        return area;
    }
}

public class Shape
{
    public string Name { get; set; }
}

We declare a type ValueTypeRectangle using the struct keyword. It has an Area() method to compute the area using the Length and Width properties and prints their values before returning. It also has a MyShape property that is of type Shape which is a reference type. Shape has only one Name property.

Value Type Assignment

The first behavior that is specific to value types is their assignment:

var firstRectangle = new ValueTypeRecta
{
    Length = 10,
    Width = 10,
    MyShape = new Shape { Name = "Square" }
};
                                       
var secondRectangle = firstRectangle;
                                       
firstRectangle.Length = 20;
firstRectangle.Width = 20;
firstRectangle.MyShape.Name = "Circle";

Console.WriteLine("First Rectangle area ->");
firstRectangle.Area();
Console.WriteLine("Second Rectangle area ->");
secondRectangle.Area();

First, we create a firstRectangle object of the ValueTypeRectangle type and assign 10 to both Length and Width. Then, we declare a secondRectangle which is assigned the firstRectangle value.

After that, we change the firstRectangle‘s Length and Width to be 20 instead of 10 and we leave secondRectangle as-is. Finally, we print the Area of both rectangles:

First Rectangle area ->
length = 20
width = 20
shape = Circle
area = 400
Second Rectangle area ->
length = 10
width = 10
shape = Circle
area = 100

As a deduction, when we assign a value type to another variable, we are copying it, and the values of its value type members are copied over to the new object. When we change one of these members in either the original object or the copy, we won’t affect the members of the other one. 

Changing a Reference Type Member

When we change the Shape name in the secondRectangle from Circle to Square, then its name also changes in the original object firstRectangle.

This is because, unlike the value type members, we copy the reference to the object and we are not creating another Shape object in the memory. Both objects reference the same instance. 

In conclusion, when we assign a value type to another variable, we create a new instance of the value type, where all value type members are copied subsequentially. But only references to reference types are copied over.

Value Type Equality

Value types use memberwise comparison to determine equality. When we compare two complex value types, we are comparing the values of their fields and properties, if all of them are equal then the value types are equal to each other, regardless of their references:

var firstRectangle = new ValueTypeRectangle
{
    Length = 10,
    Width = 10,
};
                                                 
var secondRectangle = new ValueTypeRectangle
{
    Length = 10,
    Width = 10,
};
                                                 
Assert.AreEqual(firstRectangle, secondRectangle);

Values of both firstRectangle and secondRectangle are the same, since all their respective members have the same value, despite them being two different instances of ValueTypeRectangle.

It’s worth noting that when we define a custom struct type, we can only use the Equals() method to check for equality, if we want to use the == operator then we need to overload it.

Value Type Default Value

The default value of built-in value types: 

  • byte: 0
  • bool: false
  • char: ‘\0’
  • double: 0.0
  • float: 0.0f
  • int: 0
  • DateTime: 01/01/0001 00:00:00

Value types can’t be null unless they are declared as Nullable value types. We can also use the default operator to set the default value of each type. The default value is a new instance of the value type with all its fields or properties set to their default values.

If we didn’t define a custom parameterless constructor then the implicit default parameterless constructor also creates a value-type object with its default value. We should, however, stick to using default to avoid any surprises. 

Reference Types

Contrary to value types, reference types variables contain a reference to their data. We can think of it as a pointer to the value. C# provides several built-in reference types:

  • string
  • dynamic
  • object

C# also allows declaring of a reference type with the help of several keywords:

  • delegate
  • class
  • interface

Reference Type Assignment

In contrast with ValueTypeRectangle, let’s create ReferenceTypeRectangle class to understand reference types assignment:

public class ReferenceTypeRectangle
{
    public int Length { get; set; }
    public int Width { get; set; }

    public int Area()
    {
        int area = Length * Width;
        Console.WriteLine($"length = {Length}");
        Console.WriteLine($"width = {Width}");
        Console.WriteLine($"area = {area}");

        return area;
    }
}

The ReferenceTypeRectangle has a Length and Width and an Area() method that computes an area from the current value of Length and Width properties.

Now, let’s create an instance and see how the assignment behaves:

var firstRectangle = new ReferenceTypeRectangle()
{
    Length = 10,
    Width = 10
};
                                                 
var secondRectangle = firstRectangle;
                                                 
firstRectangle.Length = 20;
firstRectangle.Width = 20;

Console.WriteLine("First Rectangle area ->");
firstRectangle.Area(); 
Console.WriteLine("Second Rectangle area ->"); 
secondRectangle.Area();

This time we create an firstRectangle object of  ReferenceTypeRectangle class and assign 10 to both Length and Width. Then, we assign the firstRectangle to a new variable, secondRectangle.

After that, we change the Length and Width of firstRectangle to be 20 and print the Area() of both objects:

First Rectangle area ->
length = 20
width = 20
area = 400
Second Rectangle area ->
length = 20
width = 20
area = 400

As a result, we see that both rectangles have 20 as Length and Width as well as 400 as area.

So, to conclude, when we assign a reference type variable from another one, then we are merely copying the reference over to the new variable, and we are not instantiating any new object. Hence, when we mutate the object from one variable, we mutate the other one as well, since they are two references pointing to the same object in memory. 

Reference Type Equality

When we compare two reference-type variables using the == operator or the Equals() method then the reference of both variables is compared. If both point to the same object in memory, then they are equal else they are not, this is regardless if their data is equal or not:

var firstRectangle = new ReferenceTypeRectangle()
{
    Length = 10,
    Width = 10
};
                                                    
var secondRectangle = new ReferenceTypeRectangle()
{
    Length = 10,
    Width = 10
};
                                                    
Assert.AreNotEqual(firstRectangle, secondRectangle);

Both firstRectangle and secondRectangle have the same member values but they are not equal as they don’t reference the same object.

If we want to override this behavior, we can override the equality by overloading the == operator and overriding Equals() and GetHashCode() methods.

Reference Type Default Value

The default value of a reference type variable is null. If we declare a reference type variable without initializing it or if we initialize it calling default, it will have a value of null until we assign an instance to it.

Memory Allocation

In .NET, we have two kinds of memory, the stack, and the heap. All reference types are allocated in the heap, and the only allocation that happens in the stack is the allocation for the variable that holds the reference to the object in the heap. For our ReferenceTypeRectangle example:

memory allocation for value types

In the example, when we declare both the firstRectangle and secondRectangle objects as reference types, then only the space of one object is acquired in a heap and the rest are the references. And even though both properties are value types – int, they are allocated in the heap since they are inside a reference type ReferenceTypeRectangle.

The runtime will either store value types in the stack or in the heap, depending on where we declare them. If we declare a value type as a method or class member then it will be allocated in the stack. But if we define it as part of a reference type, or in a collection of objects like a List<int> then it will be allocated in the heap. Thi sis the case with our value-type rectangle example:

memory allocation for reference types

In our example, all the memory of both firstRectange and secondRectange is allocated in the stack, except for the MyShape property which is allocated in the heap and referenced in the stack. This is because the Shape type is a class that is a reference type.

It’s also worth mentioning that the MyShape reference for both objects is pointing to the same object in the heap, which explains why when we change the Name of the Shape from one instance of the ValueTypeRectangle changes for the other one as well.

Value and Reference Types as Method Arguments

Value and reference types behave differently when passed as arguments to methods. When we pass a value type as an argument to the method, then we are passing a copy of it to the method.

Mutating a value-type object inside the called method does not affect the calling method value because the called and calling methods use separate copies of the value-type object.

Let’s define an IncrementRectangleLength method in the RectangleLengthIncrementer class that takes a ValueTypeRectangle and increment its length by 1:

public void IncrementRectangleLength(ValueTypeRectangle rect)
{
    rect.Length = rect.Length + 1;
    Console.WriteLine($"Length inside function = {rect.Length}");
}

After that, let’s call this method with an instance of ValueTypeRectangle that has 20 as a Length:

var lengthIncrementer = new RectangleLengthIncrementer();
var rect = new ValueTypeRectangle
{
    Length = 20
};
lengthIncrementer.IncrementRectangleLength(rect);
                                                         
Assert.AreEqual(20, rect.Length);

As a result, the rect.Length gets incremented to 21 inside the IncrementRectangleLength() method but its value is still 20 in the calling method.

Reference Types as Method Arguments

When we pass a reference type as an argument to a method, then we are passing a copy of its reference to the method. If we mutate the argument inside the called method then we will affect the instance of the calling method.

Let’s define an overload of  IncrementRectangleLength() method that takes a ReferenceTypeRectangle and increases its length by 1:

public void IncrementRectangleLength(ReferenceTypeRectangle rect)
{
    rect.Length = rect.Length + 1;
    Console.WriteLine($"Length inside function = {rect.Length}");
}

After that, let’s call this method with an instance of ReferenceTypeRectangle that has 20 as a Length:

var lengthIncrementer = new RectangleLengthIncrementer();
var rect = new ReferenceTypeRectangle
{
    Length = 20
};
lengthIncrementer.IncrementRectangleLength(rect);
                                                         
Assert.AreEqual(21, rect.Length);

As a result, the rect.Length gets incremented to 21 inside the IncrementRectangleLength() method and the Length of the original object in the calling method is also affected to be 21. 

Performance Implications of Value and Reference Types

There can be a performance difference in the usage of value and reference types.

The runtime instantiation of value types, when they are allocated in the stack memory, is faster than the instantiation of reference types, which is done in the heap memory. On the other hand, since value types are copied over when assigned or when passed as a method argument, having big value-type objects can increase the memory footprint of our code.

Thus, we should be careful to choose between value and reference types depending on our application usage and the performance needs of our code.

Boxing and Unboxing

Boxing is the conversion of a value type to an object type. It is an implicit conversion and wraps up the value inside System.Object which is stored in a heap. Unboxing is the conversion of the object type to the value type with an explicit conversion. Both are opposite of each other:

int number = 10;
                                       
object boxedNumber = number;
int unboxedNumber = (int)boxedNumber;
                                       
Assert.AreEqual(number, unboxedNumber);

We assign the integer number which is a value type to boxedNumber which is an object – a reference type. This is boxing.

Next, we are casting that boxedNumber back to the original value type int, this is called unboxing. Since the unboxing is an explicit conversion, we need to use a cast for it to work.

Boxing and unboxing are more computationally expensive than ordinary assignments, so we should be careful when using them.

Conclusion

Understanding the difference between value types and reference types is important for proper memory management. In this article, we explored what are reference and value types, and how to define custom ones. We also looked at how they differ from each other when instantiated, assigned, compared, and passed as an argument. We hope that this article explains the value and reference types in detail and enables you to make the right choices.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!