In C# programming, choosing the right data type between classes and structs is a crucial decision that impacts application performance and design. This article presents a concise comparison of Class vs Struct in C# to help you make informed choices for your projects.
Let’s dive in to understand the differences and find the best data type for each specific scenarios.
Fundamental difference: Reference Types vs. Value Types
Structs are lighter than Classes. The difference is that classes are reference types while structs are value types. This drastically impacts their memory allocation:
- Instances of a class are objects with their data allocated on the heap. The heap is a large region of memory used for dynamic memory allocation, allowing objects to have a more extended lifetime and flexible size. The heap size is only bounded by the current process available memory. The heap can contain Gigabytes of class instances.
- While instances of structures are values, residing directly on the current thread’s stack. The stack is a region of memory dedicated to the current thread. It is used for storing local variables and function call information. It operates following a Last-In-First-Out (LIFO) approach. In .NET the size of the thread stack size is typically 1MB.
Let’s note that there are some exceptions where structs may not reside on the stack, which we’ll explain in detail later.
The second major difference is that classes’ objects are passed by reference when calling a function while structs’ values are copied on the thread’s stack instead. This is explained in details in the next section.
Objects vs. Values
An instance of a class is named an object while an instance of a struct is named a value.
Objects
Reference Semantics:
- When an object is created, it resides on the heap, and a reference (memory address) to that object is stored on the stack or within other objects.
- Multiple references can point to the same object, allowing for data sharing and manipulation across different parts of the program and even different threads.
- Modifying an object’s data through one reference affects all other references pointing to the same object, as they all refer to the same memory location.
Objects’ Lifetime and Garbage Collection:
- Objects typically have a more extended lifetime and persist in memory until they are no longer reachable, at which point they become eligible for garbage collection. An object is no longer reachable when no more references point to that object.
- Garbage collection is a process that automatically reclaims memory occupied by objects that are no longer in use, ensuring efficient memory utilization. The heart of the .NET runtime – the Common Language Runtime (CLR) – houses the Garbage Collector (GC). The GC is a complex beast. Fortunately, you only have to worry about it for critical performance scenarios.
Here is a code example that illustrates the lifetime of an object:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
Console.WriteLine("Hello, World!"); { var obj = new MyClass() { Val = 3 }; // Create an object, instance of MyClass Assert.IsTrue(obj.Val == 3); MyFunction(obj); // Calls MyFunction() that modifies the object's Val Assert.IsTrue(obj.Val == 5); } // Here the reference named 'obj' is out of scope. // No more reference points to the object. // The object is no longer reachable and is eligible for garbage collection // A reference to the object is passed to the Function(). // We say it is passed by-reference. static void MyFunction(MyClass obj) { obj.Val = 5; } class MyClass { public int Val { get; set; } } |
Values
Value Semantics:
- Values, as instances of structs, adhere to value semantics.
- When a value is created, it directly resides in the memory location where it is declared or on the thread stack.
- Unlike objects, values are not shared or referenced by other variables; they exist independently, and each variable holds its own copy of the value.
- Modifying a value through one variable does not affect other variables that hold copies of the same value.
Lifetime and Stack Management:
- Values have a limited lifetime, primarily determined by their scope within the program.
- When the variable representing a value goes out of scope – this typically occurs when a method returns – the value is automatically deallocated from the stack.
- As a result, values have a shorter lifespan compared to objects residing in the heap.
Here is a code example that illustrates the lifetime of a value:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
Console.WriteLine("Hello, World!"); var val = new MyStruct() { Val = 3 }; // Create a value, instance of MyStruct Assert.IsTrue(val.Val == 3); MyFunction(val); // Calls MyFunction() that modifies a copy of the value Assert.IsTrue(val.Val == 3); // Hence the original value is not modified // A copy of the value is passed to the Function(). // We say it is passed by-value. static void MyFunction(MyStruct val) { val.Val = 5; // When this function returns, the thread unstack its data. // The copy of the value that has been modified is destroyed. } struct MyStruct { public int Val { get; set; } } |
Class vs Struct: Performance Considerations
Garbage Collection necessarily has an impact on performance:
- All references to all objects must be tracked by the GC. This is required to determine when an object no longer has any references.
- Periodically, the GC must look for objects no longer reachable to remove them.
- When some objects have been removed the GC sometimes compacts the memory, which means that some reachable objects are moved to different memory locations to avoid memory fragmentation and ensure efficient memory utilization.
Due to the absence of GC impact with structures, it is often considered that using structures can lead to the creation of faster code. This is often true, except when the size of a structure becomes too large (like more than 16 bytes footprint for its values). When passing a large value of a struct to a function, it is more costly than just passing a 4 bytes reference to an object. There are 2 solutions to this issue:
- Passing by reference a value with the keyword
ref
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
var val = new MyStruct() { Val = 3 }; Assert.IsTrue(val.Val == 3); // A reference of the original value is passed to the Function(). // Hence the original value gets modified. MyFunction(ref val); Assert.IsTrue(val.Val == 5); static void MyFunction(ref MyStruct val) { val.Val = 5; } struct MyStruct { public int Val { get; set; } } |
- Passing by reference the instance of a structure declared with the keyword
readonly
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var val = new MyStruct() { Val = 3 }; Assert.IsTrue(val.Val == 3); MyFunction(val); Assert.IsTrue(val.Val == 3); static void MyFunction(MyStruct val) { // val.Val = 5; // val is read-only and cannot be modified. // Thus it is passed by reference implicitly to MyFunction() Assert.IsTrue(val.Val == 3); } readonly struct MyStruct { // set; cannot be declared on a readonly structure, but init; can public int Val { get; init; } } |
Class vs Struct: Other Considerations
Inheritance:
Classes have support inheritance while structures don’t.
Interface:
Both classes and structures can implement an interface. However, it is advised not to implement an interface with a structure. This is because interfaces have reference semantics, and when a value type (structure) is downcasted to the interface type, it undergoes a process called boxing . Boxing involves converting the value type to an object on the heap, and this additional operation introduces performance overhead and memory allocation, counteracting the benefits of using a structure for its value semantics. Boxing is illustrated by this code sample:
1 2 3 4 5 6 7 8 9 10 11 12 |
MyStruct val = new MyStruct() { Val = 3 }; IInterface boxedVal = val; // val gets boxed into an object on the heap! boxedVal.Val = 5; Assert.IsTrue(val.Val == 3); Assert.IsTrue(boxedVal.Val == 5); // boxedVal is different than val! interface IInterface { int Val { get; set; } } struct MyStruct : IInterface { public int Val { get; set; } } |
System.Object Inheritance:
In .NET all types derive from the System.Object
class. We wrote earlier that structures don’t support inheritance but the truth is that any structure implicitly derives from the Object
class. This means that any structure value can be boxed to a reference typed as Object
.
1 2 3 4 5 6 |
MyStruct val = new MyStruct() { Val = 3 }; object boxedVal = val; // val gets boxed into an object on the heap! struct MyStruct { public int Val { get; set; } } |
Structure living on the heap:
At the beginning, we mentioned that structures’ instances are not always stored on the current thread stack. We just saw that boxing forces a structure to live within an object on the heap. The same situation occurs when a field of a class is typed with a structure:
1 2 3 4 |
MyClass obj = new MyClass() { S = new MyStruct { Val= 3 } }; struct MyStruct { public int Val { get; set; } } class MyClass { public MyStruct S { get; set; } } |
Comparison:
When comparing two references, true is returned if the two references point to the same object. When comparing two values, true is returned if the state of the values are equals.
1 2 3 4 5 6 7 8 9 10 11 12 |
MyStruct val1 = new MyStruct() { Val = 3 }; MyStruct val2 = new MyStruct() { Val = 3 }; // Assert.IsTrue(val1 == val2); the == operator must be overrided by MyStruct to be usable here Assert.IsTrue(val1.Equals(val2)); MyClass obj1 = new MyClass() { Val = 3 }; MyClass obj2 = new MyClass() { Val = 3 }; Assert.IsFalse(obj1 == obj2); Assert.IsFalse(obj1.Equals(obj2)); struct MyStruct { public int Val { get; set; } } class MyClass { public int Val { get; set; } } |
Nullability:
A reference can be null while a value cannot. Instead, a not initialized value is made of bytes initialized to 0.
1 2 3 4 5 6 7 8 9 10 11 |
var c = new Composition(); Assert.IsTrue(c.MyStructVal.Val == 0); // Not initialized struct leads to 0 Assert.IsNull(c.MyClassRef); // Not initialized class leads to null class Composition { public MyStruct MyStructVal { get; set; } public MyClass MyClassRef { get; set; } } struct MyStruct { public int Val { get; set; } } class MyClass { public MyStruct S { get; set; } } |
Notice the C# syntax with ? to allow dealing with nullable values:
1 2 3 4 5 6 7 |
MyStruct? nullableVal = null; Assert.IsTrue(nullableVal == null); nullableVal = new MyStruct { Val = 3 }; Assert.IsTrue(nullableVal.Value.Val == 3); struct MyStruct { public int Val { get; set; } } |
Collections:
The .NET runtime is specially optimized for collections of structure values. For example, int
is a structure with 4 bytes footprint and an array of int
of length 10 is a single object with a footprint of 40 bytes. Integer values are stored in this 40-byte memory range contiguously. If Person
is a class, an array of 10 persons with no null value, contains 10 references of 4 bytes each, referencing the 10 person’s objects.
Note: Choosing between a class and a struct is a significant structural decision that isn’t easily reversible. Converting a class to a struct or vice versa later may lead to complex refactoring, as nullability, comparison, and argument passing behave differently between the two data types.
Examples of Structs in C#
We just have to look at the .NET Framework types to get some examples of structures.
int
, long
, byte
, bool
, float
, double
, char
, decimal
, DateTime
, TimeSpan
are all structures. They are all lightweight types represented with at most 8 bytes. Other lightweight types that would be good candidates for being declared as a structure are:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public readonly struct Point2D { public int X { get; init; } public int Y { get; init; } } public readonly struct Color { public byte R { get; init; } public byte G { get; init; } public byte B { get; init; } } public readonly struct Money { public decimal Amount { get; init; } public string CurrencyCode { get; init; } } |
Notice that all those types are good candidates to be declared as readonly
. We say these are immutable structures. Once a value has been created it cannot be modified. Instead, a new value can be obtained from existing values like in int i1 = i2 + i3
.
Let’s note that enumeration are value types. There are structures with a single field.
1 2 3 4 5 |
Color colorR = Color.Red; Color colorB = Color.Blue; Assert.IsFalse(colorR.Equals(colorB)); enum Color { Red, Green, Blue } |
Examples of Classes in C#
Classes are well suited to implement complex dataset like this one:
1 2 3 4 5 6 7 |
public class Person { public string FirstName { get; set; } public string LastName { get; set; } public int YearOfBirth { get; set; } public string Address { get; set; } public string Email { get; set; } } |
Here it would make sense to have a class Employee that derives from the Person class, since an employee is-a person.
Classes are also well suited to implement complex behaviors like a BankAccount that can present many operations:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class BankAccount { public string AccountNumber { get; } public string AccountHolderName { get; } public decimal Balance { get; } public decimal InterestRate { get; } // Behaviors public void Deposit(decimal amount) { // ... } public void Withdraw(decimal amount) { // ... } public decimal CalculateInterest() { // ... } public void ApplyInterest() { // ... } } |
Edge case: The System.String
class is very special in the sense that it has value based comparison and is immutable, yet it is declared a class. In fact String
is so special that the runtime reserves a special heap to store its instances.
1 2 3 4 5 6 |
string hello1 = "hello"; // Create a second "hello" string object instead of modifying the "hello" string object string hello2 = "helloo".Replace("oo", "o"); Assert.IsFalse(object.ReferenceEquals(hello1, hello2)); Assert.IsTrue(hello1 == hello2); // Value comparison for string! Assert.IsTrue(hello1.Equals(hello2)); |
Guidelines for Using Classes and Structs in C#
In C#, choosing between classes and structs is a crucial decision that affects code design, memory management, and overall program performance. We can now compile guidelines to choose which one to choose:
1. Consider Performance Implications:
- Evaluate the performance requirements of your application and identify critical performance scenarios.
- Use structs in performance-critical sections of code where stack allocation and value semantics can provide efficiency gains.
2. Use Classes for Complex Objects:
- Classes are suitable for modeling complex objects that require reference semantics, shared state, and a longer lifespan.
- When dealing with entities that involve business logic, encapsulation, and inheritance, classes are the preferred choice.
3. Prefer Structs for Lightweight Data:
- Structs are more efficient for representing small, simple data that doesn’t require shared references or frequent modification.
- Use structs for lightweight entities like coordinates, dates, currencies, or simple aggregates.
4. Avoid Mutable Structs:
- Structs should generally be immutable to ensure predictable behavior and prevent potential issues with copying and sharing mutable data.
- If a mutable behavior is necessary, consider using classes or implement carefully designed methods to avoid unexpected behavior.
5. Be Mindful of Copying Overhead:
- When passing or assigning structs, keep in mind that the entire struct is copied. For large structs, this copying process can introduce performance overhead.
- Consider using ref modifiers to pass structs by reference when necessary, to minimize copying overhead.
6. Use Interfaces with Classes, Not Structs:
- While both classes and structs can implement interfaces, prefer implementing interfaces with classes.
- When a struct is cast to an interface type, it gets boxed, leading to additional performance overhead and memory allocation.
7. Favor Classes for Reference Types:
- When dealing with nullable types, events, or objects requiring dynamic memory allocation, use classes to leverage reference semantics and the garbage collector.
8. Avoid Excessive Nesting of Structs:
- Be cautious with deep nesting of structs, as it can lead to increased memory usage and potential copying overhead due to structs being value types.
By following these guidelines, you can make informed decisions about when to use classes and structs in your C# codebase, ultimately leading to well-structured, efficient, and high-performing applications.
Conclusion
Understanding the differences between structs and classes in C# is vital for making informed decisions in your code. By leveraging their unique characteristics, you can optimize performance and maintainability effectively. These decisions play a pivotal role in optimizing your code for enhanced performance, as well as ensuring its long-term maintainability. Embrace the power of these fundamental concepts, and leverage their strengths to craft robust and efficient C# applications.
C# is a versatile language that has evolved significantly in recent years. The C# team worked hard to promote managed pointers to classes and structures through extended usages of the ref keyword, that we just saw in this article to pass a value by-reference. You can read it all in this article :Managed pointers, Span, ref struct, C#11 ref fields and the scoped keyword.
Great article. You should write one on Task and ValueTask as well!
An insightful breakdown of the differences between classes and structs in C#. Your article makes it easier for developers to choose the right data type for their specific needs. Well done!