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.
Let’s start.
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:
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:
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.