Skip to main content

5.3 - Value Types vs. Reference Types

In Module 2, we briefly introduced the concept of value types and reference types. Now that we're diving deeper into Object-Oriented Programming, it's essential to thoroughly understand this distinction, as it affects how your data behaves in memory and can have significant implications for your game's performance.

The Two Type Categories in C#

C# types are divided into two categories:

  1. Value Types: Store their data directly
  2. Reference Types: Store a reference (address) to their data

This distinction affects how variables behave when they're assigned, passed to methods, or compared.

Memory Allocation: Stack vs. Heap

To understand value and reference types, we first need to understand how memory is organized in a C# program:

The Stack

  • A region of memory that stores local variables and method parameters
  • Operates in a Last-In-First-Out (LIFO) manner
  • Memory is automatically allocated and deallocated as methods are called and return
  • Fast access and efficient for small, short-lived data
  • Limited in size

The Heap

  • A region of memory used for dynamic allocation
  • Managed by the garbage collector in .NET
  • Can grow as needed (larger than the stack)
  • Slightly slower access than the stack
  • Used for data that needs to persist beyond method calls or has an unpredictable lifetime

Value Types

Value types directly contain their data and are typically stored on the stack.

Common Value Types in C#

  • Simple Types:
    • Numeric types (int, float, double, decimal)
    • bool
    • char
  • Struct Types:
    • User-defined structs
    • DateTime
    • TimeSpan
  • Enum Types:
    • User-defined enumerations
  • Nullable Value Types (e.g., int?, bool?)

Key Characteristics of Value Types

  1. Direct Storage: The actual data is stored directly in the variable.
  2. Copy on Assignment: When you assign one value type to another, a complete copy of the data is made.
  3. Independent Instances: Changes to one variable don't affect other variables.
  4. Default Initialization: Value types are automatically initialized to their default values when declared.
  5. Cannot Be null: Standard value types cannot be null (though nullable value types can).
  6. Typically Smaller and Faster: For simple data, value types are more efficient.

Example of Value Type Behavior

// Value type example
int a = 10;
int b = a; // 'b' gets a copy of the value in 'a'

Console.WriteLine($"a: {a}, b: {b}"); // Output: a: 10, b: 10

// Modify 'b'
b = 20;

// 'a' remains unchanged because 'b' is an independent copy
Console.WriteLine($"a: {a}, b: {b}"); // Output: a: 10, b: 20

Reference Types

Reference types store a reference (memory address) to their data, which is stored on the heap.

Common Reference Types in C#

  • Class Types:
    • User-defined classes
    • string (although it behaves like a value type in some ways)
    • object (base class for all types in C#)
  • Interface Types
  • Array Types (even arrays of value types)
  • Delegate Types
  • Record Types (C# 9)

Key Characteristics of Reference Types

  1. Indirect Storage: The variable stores a reference to the data, not the data itself.
  2. Reference on Assignment: When you assign one reference type to another, only the reference is copied, not the data.
  3. Shared Instances: Multiple variables can reference the same object, so changes through one variable affect what other variables see.
  4. Default Initialization: Reference types are automatically initialized to null when declared.
  5. Can Be null: Reference types can be null, indicating they don't reference any object.
  6. Garbage Collection: Memory for unused objects is automatically reclaimed by the garbage collector.

Example of Reference Type Behavior

// Define a simple class (reference type)
public class Player
{
public string Name;
public int Health;
}

// Create a Player object
Player player1 = new Player { Name = "Hero", Health = 100 };

// Assign player1 to player2
Player player2 = player1; // Both variables now reference the same object

Console.WriteLine($"player1: {player1.Name}, Health: {player1.Health}");
Console.WriteLine($"player2: {player2.Name}, Health: {player2.Health}");
// Output:
// player1: Hero, Health: 100
// player2: Hero, Health: 100

// Modify the object through player2
player2.Health = 80;

// The change is visible through both variables because they reference the same object
Console.WriteLine($"player1: {player1.Name}, Health: {player1.Health}");
Console.WriteLine($"player2: {player2.Name}, Health: {player2.Health}");
// Output:
// player1: Hero, Health: 80
// player2: Hero, Health: 80

// Assign a new object to player2
player2 = new Player { Name = "Villain", Health = 150 };

// Now player1 and player2 reference different objects
Console.WriteLine($"player1: {player1.Name}, Health: {player1.Health}");
Console.WriteLine($"player2: {player2.Name}, Health: {player2.Health}");
// Output:
// player1: Hero, Health: 80
// player2: Villain, Health: 150

Visual Representation

Let's visualize how value types and reference types are stored in memory:

Value Types in Memory

Stack
+----------------+
| int a = 10 | <- The value 10 is stored directly in the variable 'a'
+----------------+
| int b = 10 | <- When b = a, a copy of the value is made
+----------------+

Reference Types in Memory

Stack                  Heap
+----------------+ +----------------+
| Player player1 | --> | Name: "Hero" |
+----------------+ | Health: 80 |
+----------------+

+----------------+ +----------------+
| Player player2 | --> | Name: "Villain"|
+----------------+ | Health: 150 |
+----------------+

Parameter Passing: By Value vs. By Reference

Understanding value and reference types is crucial when passing parameters to methods.

Passing Value Types

By default, value types are passed by value, meaning a copy of the data is passed to the method:

void ModifyValue(int x)
{
x = 100; // Modifies the local copy, not the original
Console.WriteLine($"Inside method: x = {x}");
}

int number = 5;
Console.WriteLine($"Before method: number = {number}"); // Output: 5
ModifyValue(number); // Output: Inside method: x = 100
Console.WriteLine($"After method: number = {number}"); // Output: 5 (unchanged)

Passing Reference Types

Reference types are also passed by value, but what's passed is a copy of the reference, not a copy of the object:

void ModifyPlayer(Player p)
{
p.Health = 50; // Modifies the actual object, not just the local reference
Console.WriteLine($"Inside method: Health = {p.Health}");
}

Player hero = new Player { Name = "Hero", Health = 100 };
Console.WriteLine($"Before method: Health = {hero.Health}"); // Output: 100
ModifyPlayer(hero); // Output: Inside method: Health = 50
Console.WriteLine($"After method: Health = {hero.Health}"); // Output: 50 (changed)

Using ref and out Parameters

You can explicitly pass value types by reference using the ref or out keywords:

void ModifyValueByRef(ref int x)
{
x = 100; // Modifies the original variable, not a copy
Console.WriteLine($"Inside method: x = {x}");
}

int number = 5;
Console.WriteLine($"Before method: number = {number}"); // Output: 5
ModifyValueByRef(ref number); // Output: Inside method: x = 100
Console.WriteLine($"After method: number = {number}"); // Output: 100 (changed)

Performance Implications

The choice between value types and reference types can affect your game's performance:

Value Types

  • Pros:

    • No heap allocation (for stack-based variables)
    • No garbage collection overhead
    • Better cache locality
    • Faster for small, simple data
  • Cons:

    • Copying large structs can be expensive
    • Boxing (converting to reference type) has overhead

Reference Types

  • Pros:

    • Efficient for large data structures (only the reference is copied)
    • Support for inheritance and polymorphism
    • Can represent null state naturally
  • Cons:

    • Heap allocation overhead
    • Garbage collection pressure
    • Potential cache misses
    • Slightly slower access (indirection)

Value Types and Reference Types in Unity

Unity uses both value types and reference types extensively:

Common Value Types in Unity

  • Vector2, Vector3, Vector4
  • Quaternion
  • Color
  • Rect
  • Bounds
  • RaycastHit
  • Custom structs

Common Reference Types in Unity

  • GameObject
  • Component and all derived classes (Transform, Rigidbody, etc.)
  • MonoBehaviour scripts
  • Material
  • Texture
  • AudioClip
  • Custom classes

Unity-Specific Considerations

  1. Struct Performance: Unity uses structs for many common types (like Vector3) for performance reasons. These are passed by value, which is efficient for small data.

  2. GameObject and Component References: These are reference types, so be careful about null references and understand that multiple variables can point to the same object.

  3. Serialization: Unity can serialize both value and reference types, but there are limitations and special considerations for each.

  4. Garbage Collection: Creating many temporary reference type objects can lead to garbage collection pauses, which can cause frame rate drops. Value types help avoid this.

Practical Examples

Example 1: Position Updating

// Using value types (Vector3)
public class PlayerMovement : MonoBehaviour
{
public float speed = 5f;

void Update()
{
// Vector3 is a struct (value type)
Vector3 position = transform.position;

// Modify the copy
position.x += Input.GetAxis("Horizontal") * speed * Time.deltaTime;
position.z += Input.GetAxis("Vertical") * speed * Time.deltaTime;

// Assign the modified copy back
transform.position = position;
}
}

Example 2: Inventory System

// Item as a value type (struct)
public struct ItemData
{
public int id;
public string name;
public int value;
public float weight;
}

// Inventory as a reference type (class)
public class Inventory : MonoBehaviour
{
private List<ItemData> items = new List<ItemData>();

public void AddItem(ItemData item)
{
// A copy of the item is added to the list
items.Add(item);
}

public void ModifyItem(int index, int newValue)
{
if (index >= 0 && index < items.Count)
{
// Need to get the item, modify it, and put it back
ItemData item = items[index];
item.value = newValue;
items[index] = item;
}
}
}

Example 3: Enemy AI

// Enemy as a reference type (class)
public class Enemy : MonoBehaviour
{
public int health = 100;
public float moveSpeed = 3f;

private Transform playerTransform;

void Start()
{
// Store a reference to the player's transform
playerTransform = GameObject.FindWithTag("Player").transform;
}

void Update()
{
// Move towards the player
if (playerTransform != null)
{
Vector3 direction = (playerTransform.position - transform.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;
}
}

public void TakeDamage(int amount)
{
health -= amount;
if (health <= 0)
Destroy(gameObject);
}
}

// EnemyManager as a reference type (class)
public class EnemyManager : MonoBehaviour
{
private List<Enemy> enemies = new List<Enemy>();

public void RegisterEnemy(Enemy enemy)
{
// Store a reference to the enemy
enemies.Add(enemy);
}

public void DamageAllEnemies(int amount)
{
foreach (Enemy enemy in enemies)
{
// Modify the actual enemy objects through references
enemy.TakeDamage(amount);
}

// Remove destroyed enemies from the list
enemies.RemoveAll(e => e == null);
}
}

Common Pitfalls and Best Practices

Pitfalls

  1. Unintended Shared References: Multiple variables referencing the same object can lead to unexpected changes.

  2. Expensive Value Type Copying: Copying large structs repeatedly can hurt performance.

  3. Boxing and Unboxing: Converting between value types and reference types (e.g., int to object) incurs performance overhead.

  4. Null Reference Exceptions: Reference types can be null, which can cause runtime errors if not checked.

Best Practices

  1. Choose Appropriately:

    • Use value types for small, simple data that benefits from stack allocation
    • Use reference types for larger data structures, polymorphic behavior, or when identity matters
  2. Be Mindful of Copying:

    • Avoid copying large structs frequently
    • Consider using ref parameters for large structs when appropriate
  3. Watch for Garbage Collection:

    • Minimize allocations in performance-critical code
    • Reuse objects instead of creating new ones
    • Consider object pooling for frequently created/destroyed objects
  4. Null Checking:

    • Always check reference types for null before using them
    • Consider using nullable reference types (C# 8) to make null intent explicit
  5. Immutability:

    • Consider making value types immutable to avoid confusion
    • Use readonly struct (C# 7.2) for immutable value types

Conclusion

Understanding the difference between value types and reference types is fundamental to writing efficient and correct C# code, especially in Unity where performance is critical.

Value types are great for small, simple data that needs to be copied, while reference types are better for larger, more complex data or when you need to share data between different parts of your code.

By choosing the right type for your data and understanding how it behaves in memory, you can write more efficient, bug-free code for your Unity games.

In the next section, we'll explore constructors, which are special methods used to initialize objects when they're created.

Practice Exercise

Exercise: Create a simple game state system with the following components:

  1. A PlayerStats struct that contains player statistics (health, mana, experience, etc.)
  2. A GameState class that manages the overall game state, including a reference to the player stats
  3. A method that demonstrates how modifying the struct requires reassignment, while modifying a class doesn't
  4. A method that demonstrates passing both types to methods, with and without the ref keyword

Think about the performance implications of your design choices and how they might affect a real game.