7.5 - throw Statement
So far, we've focused on handling exceptions that occur during program execution. Now, we'll explore how to create and throw your own exceptions using the throw
statement. This is an essential skill for communicating error conditions in your code and building robust game systems.
Understanding the throw Statement
The throw
statement allows you to:
- Create and throw new exceptions
- Re-throw caught exceptions
- Signal error conditions that cannot be handled locally
- Provide detailed information about what went wrong
Basic Syntax
The basic syntax of the throw
statement is:
throw expression;
Where expression
is an object of a type derived from System.Exception
.
Creating and Throwing Exceptions
The most common use of throw
is to create and throw a new exception:
public void SetPlayerHealth(int value)
{
if (value < 0)
{
throw new ArgumentException("Health cannot be negative", nameof(value));
}
playerHealth = value;
}
In this example:
- We check if the health value is valid
- If not, we throw an
ArgumentException
with a descriptive message - We include the parameter name for better debugging
Common Exception Types to Throw
C# provides many built-in exception types for different scenarios:
Exception Type | When to Use It |
---|---|
ArgumentException | When an argument value is invalid |
ArgumentNullException | When an argument is unexpectedly null |
ArgumentOutOfRangeException | When an argument is outside the valid range |
InvalidOperationException | When the operation is invalid for the object's current state |
NotImplementedException | When a method or property is not yet implemented |
IOException | When an I/O error occurs |
FileNotFoundException | When a file cannot be found |
UnauthorizedAccessException | When access to a resource is denied |
FormatException | When a string format is invalid |
TimeoutException | When an operation times out |
Let's see examples of using these exception types in game development:
// ArgumentException
public void SetDifficulty(int level)
{
if (level < 1 || level > 5)
{
throw new ArgumentException("Difficulty must be between 1 and 5", nameof(level));
}
gameDifficulty = level;
}
// ArgumentNullException
public void RegisterPlayer(Player player)
{
if (player == null)
{
throw new ArgumentNullException(nameof(player), "Player cannot be null");
}
players.Add(player.Id, player);
}
// ArgumentOutOfRangeException
public Item GetInventoryItem(int index)
{
if (index < 0 || index >= inventory.Count)
{
throw new ArgumentOutOfRangeException(nameof(index),
$"Index must be between 0 and {inventory.Count - 1}");
}
return inventory[index];
}
// InvalidOperationException
public void StartGame()
{
if (players.Count < minPlayers)
{
throw new InvalidOperationException(
$"Cannot start game with fewer than {minPlayers} players");
}
gameState = GameState.Playing;
}
// NotImplementedException
public virtual void SpecialAbility()
{
// Base class doesn't implement this - derived classes should override
throw new NotImplementedException("SpecialAbility must be implemented by derived classes");
}
// IOException / FileNotFoundException
public SaveData LoadGame(string saveName)
{
string path = Path.Combine(Application.persistentDataPath, $"{saveName}.sav");
if (!File.Exists(path))
{
throw new FileNotFoundException($"Save file '{saveName}' not found", path);
}
try
{
string json = File.ReadAllText(path);
return JsonUtility.FromJson<SaveData>(json);
}
catch (IOException e)
{
throw new IOException($"Error reading save file '{saveName}'", e);
}
}
Throwing with Additional Information
When throwing exceptions, include as much relevant information as possible:
public Enemy SpawnEnemy(string enemyType, Vector3 position)
{
if (!enemyPrefabs.ContainsKey(enemyType))
{
string availableTypes = string.Join(", ", enemyPrefabs.Keys);
throw new ArgumentException(
$"Unknown enemy type: {enemyType}. Available types: {availableTypes}",
nameof(enemyType));
}
// Spawn the enemy...
return Instantiate(enemyPrefabs[enemyType], position, Quaternion.identity);
}
This exception message not only tells you what went wrong but also what valid options are available.
Re-throwing Exceptions
Sometimes you want to catch an exception, perform some action, and then re-throw it:
1. Simple Re-throw
public void ProcessLevel(string levelName)
{
try
{
LoadLevel(levelName);
}
catch (Exception e)
{
Debug.LogError($"Error loading level: {e.Message}");
// Re-throw the same exception
throw;
}
}
Using throw;
(without an expression) preserves the original exception's stack trace, which is usually what you want.
2. Wrapping Exceptions
Sometimes you want to catch a low-level exception and throw a higher-level one:
public PlayerData LoadPlayerProfile(string profileId)
{
try
{
string path = Path.Combine(Application.persistentDataPath, $"Profiles/{profileId}.json");
string json = File.ReadAllText(path);
return JsonUtility.FromJson<PlayerData>(json);
}
catch (FileNotFoundException e)
{
// Wrap in a more specific exception
throw new ProfileNotFoundException($"Profile '{profileId}' not found", e);
}
catch (JsonException e)
{
// Wrap in a more specific exception
throw new ProfileCorruptedException($"Profile '{profileId}' is corrupted", e);
}
}
Note how we pass the original exception as the innerException
parameter. This preserves the original error information while adding context.
Exception Propagation
When an exception is thrown, it "bubbles up" the call stack until it's caught or reaches the top level:
public void StartGame()
{
try
{
InitializeGameWorld();
SpawnPlayer();
StartGameLoop();
}
catch (Exception e)
{
Debug.LogError($"Game initialization failed: {e.Message}");
ShowErrorScreen("Failed to start game. Please try again.");
}
}
private void InitializeGameWorld()
{
LoadLevel("Level1");
SpawnEnemies();
}
private void LoadLevel(string levelName)
{
if (!levelData.ContainsKey(levelName))
{
throw new ArgumentException($"Level '{levelName}' not found", nameof(levelName));
}
// Load the level...
}
In this example:
LoadLevel
throws an exception- The exception propagates up to
InitializeGameWorld
- Since
InitializeGameWorld
doesn't catch it, it continues up toStartGame
StartGame
catches the exception and handles it
This propagation allows you to handle exceptions at the appropriate level of abstraction.
When to Throw Exceptions
Exceptions should be used for exceptional conditions, not for normal control flow. Here are guidelines for when to throw exceptions:
Good Reasons to Throw Exceptions
- Invalid arguments: When a method receives arguments that don't make sense
- Precondition violations: When a method is called in an inappropriate context
- Resource failures: When required resources are unavailable
- Unexpected states: When the system is in a state that shouldn't be possible
- External system failures: When interactions with external systems fail
Bad Reasons to Throw Exceptions
- Expected conditions: Use return values or out parameters instead
- Control flow: Use if/else statements or other control structures
- Performance-critical code: Exceptions have overhead
- Recoverable situations: If the caller can easily check beforehand
Game Development Examples
Let's look at some practical examples of using throw
in game development:
1. Validating Game Configuration
public void InitializeGame(GameConfig config)
{
// Validate required configuration
if (config == null)
{
throw new ArgumentNullException(nameof(config), "Game configuration cannot be null");
}
if (string.IsNullOrEmpty(config.StartingLevel))
{
throw new ArgumentException("Starting level must be specified", nameof(config));
}
if (config.MaxPlayers < 1)
{
throw new ArgumentException("Max players must be at least 1", nameof(config));
}
if (config.Difficulty < GameDifficulty.Easy || config.Difficulty > GameDifficulty.Nightmare)
{
throw new ArgumentException("Invalid difficulty setting", nameof(config));
}
// Initialize the game with the validated configuration
currentLevel = config.StartingLevel;
maxPlayers = config.MaxPlayers;
difficulty = config.Difficulty;
Debug.Log($"Game initialized with difficulty: {difficulty}, max players: {maxPlayers}");
}
2. Resource Management
public Texture2D GetTexture(string textureName)
{
if (string.IsNullOrEmpty(textureName))
{
throw new ArgumentNullException(nameof(textureName), "Texture name cannot be null or empty");
}
// Check if the texture is already loaded
if (textureCache.TryGetValue(textureName, out Texture2D texture))
{
return texture;
}
// Try to load the texture
texture = Resources.Load<Texture2D>($"Textures/{textureName}");
if (texture == null)
{
throw new ResourceNotFoundException($"Texture '{textureName}' not found");
}
// Cache the texture for future use
textureCache[textureName] = texture;
return texture;
}
3. Game State Validation
public void TransitionToState(GameState newState)
{
// Check if the transition is valid
if (!IsValidTransition(currentState, newState))
{
throw new InvalidOperationException(
$"Cannot transition from {currentState} to {newState}");
}
// Perform state exit actions
ExitState(currentState);
// Update the state
GameState previousState = currentState;
currentState = newState;
// Perform state entry actions
try
{
EnterState(newState);
}
catch (Exception e)
{
// If entering the new state fails, revert to the previous state
Debug.LogError($"Failed to enter state {newState}: {e.Message}");
currentState = previousState;
// Re-throw the exception
throw;
}
Debug.Log($"Transitioned from {previousState} to {newState}");
}
private bool IsValidTransition(GameState from, GameState to)
{
// Define valid state transitions
switch (from)
{
case GameState.MainMenu:
return to == GameState.Loading || to == GameState.Options;
case GameState.Loading:
return to == GameState.Playing || to == GameState.MainMenu;
case GameState.Playing:
return to == GameState.Paused || to == GameState.GameOver;
case GameState.Paused:
return to == GameState.Playing || to == GameState.MainMenu;
case GameState.GameOver:
return to == GameState.MainMenu || to == GameState.Loading;
case GameState.Options:
return to == GameState.MainMenu;
default:
return false;
}
}
4. Custom Game Logic
public void CastSpell(Spell spell, Character caster, Character target)
{
// Validate spell
if (spell == null)
{
throw new ArgumentNullException(nameof(spell), "Spell cannot be null");
}
// Validate caster
if (caster == null)
{
throw new ArgumentNullException(nameof(caster), "Caster cannot be null");
}
// Validate target
if (target == null)
{
throw new ArgumentNullException(nameof(target), "Target cannot be null");
}
// Check if the caster has enough mana
if (caster.CurrentMana < spell.ManaCost)
{
throw new InsufficientManaException(
$"Not enough mana to cast {spell.Name}. Required: {spell.ManaCost}, Current: {caster.CurrentMana}");
}
// Check if the spell is on cooldown
if (caster.IsSpellOnCooldown(spell))
{
float remainingCooldown = caster.GetRemainingCooldown(spell);
throw new SpellOnCooldownException(
$"{spell.Name} is on cooldown for {remainingCooldown:F1} more seconds");
}
// Check range
float distance = Vector3.Distance(caster.transform.position, target.transform.position);
if (distance > spell.Range)
{
throw new TargetOutOfRangeException(
$"Target is out of range. Maximum range: {spell.Range}, Current distance: {distance:F1}");
}
// Cast the spell
caster.CurrentMana -= spell.ManaCost;
caster.StartSpellCooldown(spell);
spell.Apply(caster, target);
Debug.Log($"{caster.Name} cast {spell.Name} on {target.Name}");
}
Creating Custom Exception Classes
For domain-specific errors, it's often useful to create custom exception classes:
// Base class for all game-related exceptions
public class GameException : Exception
{
public GameException() : base() { }
public GameException(string message) : base(message) { }
public GameException(string message, Exception innerException)
: base(message, innerException) { }
}
// Specific exception types
public class ResourceNotFoundException : GameException
{
public string ResourceName { get; }
public string ResourceType { get; }
public ResourceNotFoundException(string resourceName, string resourceType = "")
: base($"Resource not found: {resourceName} {(string.IsNullOrEmpty(resourceType) ? "" : $"(Type: {resourceType})")}")
{
ResourceName = resourceName;
ResourceType = resourceType;
}
}
public class InsufficientManaException : GameException
{
public int Required { get; }
public int Available { get; }
public InsufficientManaException(string message) : base(message) { }
public InsufficientManaException(int required, int available)
: base($"Insufficient mana. Required: {required}, Available: {available}")
{
Required = required;
Available = available;
}
}
public class SpellOnCooldownException : GameException
{
public float RemainingCooldown { get; }
public SpellOnCooldownException(string message) : base(message) { }
public SpellOnCooldownException(float remainingCooldown)
: base($"Spell is on cooldown for {remainingCooldown:F1} more seconds")
{
RemainingCooldown = remainingCooldown;
}
}
public class TargetOutOfRangeException : GameException
{
public float MaxRange { get; }
public float ActualDistance { get; }
public TargetOutOfRangeException(string message) : base(message) { }
public TargetOutOfRangeException(float maxRange, float actualDistance)
: base($"Target is out of range. Maximum range: {maxRange}, Current distance: {actualDistance:F1}")
{
MaxRange = maxRange;
ActualDistance = actualDistance;
}
}
Custom exceptions allow you to:
- Categorize errors more specifically
- Include domain-specific information
- Handle different error types differently
- Make your code more self-documenting
Using throw Expressions (C# 7.0+)
In C# 7.0 and later, you can use throw
as an expression in certain contexts:
1. Conditional (Ternary) Operator
public Player GetPlayerById(string id)
{
return playerDictionary.ContainsKey(id)
? playerDictionary[id]
: throw new ArgumentException($"Player with ID {id} not found", nameof(id));
}
2. Null-Coalescing Operator
public void ProcessPlayerData(PlayerData data)
{
// If data is null, throw an exception
data = data ?? throw new ArgumentNullException(nameof(data));
// Process the data...
}
3. Expression-Bodied Members
public string GetRequiredSetting(string key) =>
settings.TryGetValue(key, out string value)
? value
: throw new KeyNotFoundException($"Required setting '{key}' not found");
Performance Considerations
Throwing exceptions has performance implications:
- Creating exception objects is relatively expensive
- Capturing stack traces adds overhead
- Unwinding the call stack takes time
For performance-critical code, consider alternatives:
1. Return Success/Failure Codes
// Instead of throwing exceptions
public bool TryGetEnemy(string id, out Enemy enemy)
{
if (enemyDictionary.TryGetValue(id, out enemy))
{
return true;
}
enemy = null;
return false;
}
// Usage
if (!TryGetEnemy("goblin_1", out Enemy enemy))
{
Debug.LogWarning("Enemy not found");
return;
}
// Use the enemy...
2. Return Nullable Types
// Instead of throwing exceptions
public Item? GetItem(int id)
{
if (itemDatabase.ContainsKey(id))
{
return itemDatabase[id];
}
return null;
}
// Usage
Item? item = GetItem(123);
if (item.HasValue)
{
// Use the item...
}
else
{
Debug.LogWarning("Item not found");
}
3. Result Pattern
public struct Result<T>
{
public bool Success { get; }
public T Value { get; }
public string ErrorMessage { get; }
private Result(bool success, T value, string errorMessage)
{
Success = success;
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Ok(T value) => new Result<T>(true, value, null);
public static Result<T> Fail(string errorMessage) => new Result<T>(false, default, errorMessage);
}
// Usage
public Result<PlayerData> LoadPlayerData(string playerId)
{
string path = Path.Combine(Application.persistentDataPath, $"{playerId}.json");
if (!File.Exists(path))
{
return Result<PlayerData>.Fail($"Player data file not found for ID: {playerId}");
}
try
{
string json = File.ReadAllText(path);
PlayerData data = JsonUtility.FromJson<PlayerData>(json);
return Result<PlayerData>.Ok(data);
}
catch (Exception e)
{
return Result<PlayerData>.Fail($"Error loading player data: {e.Message}");
}
}
// Client code
Result<PlayerData> result = LoadPlayerData("player123");
if (result.Success)
{
PlayerData data = result.Value;
// Use the data...
}
else
{
Debug.LogWarning(result.ErrorMessage);
// Handle the error...
}
In Unity, it's particularly important to avoid throwing exceptions in performance-critical code paths like Update()
, FixedUpdate()
, or rendering code. For these scenarios, consider using the alternative patterns described above.
Unity-Specific Considerations
1. Exception Handling in MonoBehaviour Methods
Unity's component lifecycle methods (like Update
, Start
, etc.) have built-in exception handling. If an exception occurs in these methods, Unity will:
- Log the exception to the console
- Disable the component that threw the exception
- Continue running the game
This behavior can make debugging difficult, as your game might continue running in an inconsistent state. Consider adding your own try-catch blocks in critical methods:
void Update()
{
try
{
// Update game logic
UpdatePlayerMovement();
UpdateEnemies();
CheckCollisions();
}
catch (Exception e)
{
Debug.LogError($"Critical error in Update: {e.Message}\n{e.StackTrace}");
// Pause the game instead of letting it continue in a broken state
Time.timeScale = 0;
// Show an error UI
errorPanel.SetActive(true);
errorText.text = "An error occurred. Please restart the game.";
}
}
2. Coroutines and Exceptions
Exceptions in coroutines can be particularly tricky, as they don't propagate normally:
// This won't catch exceptions from the coroutine
try
{
StartCoroutine(LoadLevelCoroutine());
}
catch (Exception e)
{
// This catch block will never execute for exceptions inside the coroutine
Debug.LogError(e);
}
Instead, handle exceptions inside the coroutine:
private IEnumerator LoadLevelCoroutine()
{
try
{
// Coroutine logic that might throw exceptions
yield return StartCoroutine(LoadAssets());
yield return StartCoroutine(SpawnEntities());
yield return StartCoroutine(InitializeAI());
}
catch (Exception e)
{
Debug.LogError($"Error in LoadLevelCoroutine: {e.Message}");
onLevelLoadFailed?.Invoke(e.Message);
yield break;
}
onLevelLoadCompleted?.Invoke();
}
Conclusion
The throw
statement is a powerful tool for signaling and handling error conditions in your code. By using it effectively, you can:
- Communicate clear error information
- Ensure your methods are used correctly
- Fail fast when invalid conditions are detected
- Create robust, self-validating APIs
Remember these key principles:
- Throw exceptions for exceptional conditions, not normal control flow
- Include detailed information in your exception messages
- Create custom exception types for domain-specific errors
- Consider performance implications in critical code paths
- Handle exceptions at the appropriate level of abstraction
In the next section, we'll explore common .NET exception types and when to use them in your game code.