Skip to main content

7.2 - try-catch Blocks

Now that we understand what exceptions are and why they matter, let's explore the primary mechanism C# provides for handling them: the try-catch block.

The Basics of try-catch

The try-catch block is a language construct that allows you to:

  1. Attempt to execute code that might cause an exception (try)
  2. Catch and handle any exceptions that occur (catch)
  3. Execute cleanup code regardless of whether an exception occurred (finally)

Here's the basic syntax:

try
{
// Code that might throw an exception
}
catch (ExceptionType e)
{
// Code to handle the exception
}

A Simple Example

Let's start with a straightforward example: parsing a string to an integer.

public int ParsePlayerScore(string scoreText)
{
try
{
// This might throw FormatException if scoreText isn't a valid number
return int.Parse(scoreText);
}
catch (FormatException)
{
Console.WriteLine("Invalid score format. Using default value.");
return 0;
}
}

// Usage
int score1 = ParsePlayerScore("500"); // Returns 500
int score2 = ParsePlayerScore("High"); // Returns 0 and prints error message

In this example:

  • We attempt to parse a string to an integer in the try block
  • If the string isn't a valid number, a FormatException is thrown
  • Our catch block handles the exception by returning a default value

Without the try-catch block, the second call would crash our program. With it, we gracefully handle the error and continue execution.

Game Development Example: Loading Player Data

Let's look at a more game-relevant example: loading player data from a file.

public PlayerData LoadPlayerData(string playerName)
{
string filePath = $"SaveData/{playerName}.json";

try
{
// Attempt to read the file
string json = File.ReadAllText(filePath);

// Attempt to parse the JSON
PlayerData data = JsonUtility.FromJson<PlayerData>(json);

// If we get here, both operations succeeded
Debug.Log($"Successfully loaded data for player: {playerName}");
return data;
}
catch (FileNotFoundException)
{
// Handle the specific case where the file doesn't exist
Debug.Log($"No save file found for player: {playerName}. Creating new data.");
return new PlayerData(playerName);
}
catch (JsonException)
{
// Handle the specific case where the JSON is invalid
Debug.LogWarning($"Save file for player {playerName} is corrupted. Creating new data.");
return new PlayerData(playerName);
}
catch (Exception e)
{
// Catch any other unexpected exceptions
Debug.LogError($"Unexpected error loading player data: {e.Message}");
return new PlayerData(playerName);
}
}

This example demonstrates several important concepts:

  1. Multiple catch blocks: We can have multiple catch blocks to handle different types of exceptions differently.
  2. Exception hierarchy: The order of catch blocks matters. More specific exceptions should come before more general ones.
  3. Graceful degradation: Instead of crashing, we provide fallback behavior (creating new player data).
  4. Logging: We log different levels of information based on the severity of the issue.

Catching Multiple Exception Types

There are several ways to catch multiple exception types:

1. Multiple catch blocks (as shown above)

try
{
// Risky code
}
catch (FileNotFoundException fnfe)
{
// Handle file not found
}
catch (UnauthorizedAccessException uae)
{
// Handle permission issues
}

2. Catching multiple exceptions in one block (C# 6.0 and later)

try
{
// Risky code
}
catch (FileNotFoundException | UnauthorizedAccessException e)
{
// Handle both exception types the same way
Debug.LogError($"File access error: {e.Message}");
}

3. Using exception filters (C# 6.0 and later)

try
{
// Risky code
}
catch (Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException)
{
// Handle file or directory not found
}
catch (Exception e) when (e.Message.Contains("access denied"))
{
// Handle access denied errors
}

The Exception Object

When you catch an exception, you get an exception object that contains valuable information about what went wrong. Here's how to use it:

try
{
// Risky code
}
catch (Exception e)
{
// Access exception properties
Debug.LogError($"Error: {e.Message}");
Debug.LogError($"Stack trace: {e.StackTrace}");

if (e.InnerException != null)
{
Debug.LogError($"Caused by: {e.InnerException.Message}");
}
}

Important properties of the Exception object:

  • Message: A human-readable description of the error
  • StackTrace: Shows the call stack at the point where the exception was thrown
  • InnerException: If this exception was caused by another exception
  • Source: The name of the application or object that caused the error
  • HResult: A numeric error code (mostly used for interop scenarios)

Exception Handling Best Practices

1. Only Catch Exceptions You Can Handle

Don't catch exceptions unless you have a specific recovery strategy:

// Good: Specific exception, clear handling strategy
try
{
playerData = LoadPlayerData(playerId);
}
catch (FileNotFoundException)
{
playerData = CreateDefaultPlayerData();
SavePlayerData(playerData);
}

// Bad: Catching and ignoring exceptions
try
{
playerData = LoadPlayerData(playerId);
}
catch (Exception)
{
// Empty catch block or just logging without recovery
}

2. Keep try Blocks Small and Focused

Each try block should focus on a single operation that might fail:

// Good: Focused try blocks
try
{
string json = File.ReadAllText(filePath);
}
catch (IOException e)
{
// Handle IO issues
}

try
{
PlayerData data = JsonUtility.FromJson<PlayerData>(json);
}
catch (JsonException e)
{
// Handle JSON parsing issues
}

// Bad: Too many operations in one try block
try
{
string json = File.ReadAllText(filePath);
PlayerData data = JsonUtility.FromJson<PlayerData>(json);
data.lastPlayedDate = DateTime.Now;
data.playCount++;
string updatedJson = JsonUtility.ToJson(data);
File.WriteAllText(filePath, updatedJson);
}
catch (Exception e)
{
// It's unclear which operation failed
}

3. Avoid Catching System.Exception (Usually)

Catching System.Exception can hide bugs and make debugging harder:

// Generally avoid this pattern
try
{
// Complex code
}
catch (Exception)
{
// Now we'll never know what went wrong
}

Instead, catch specific exceptions you expect and can handle. If you must catch System.Exception, at least log the details:

try
{
// Complex code
}
catch (Exception e)
{
Debug.LogError($"Unexpected error: {e.Message}\n{e.StackTrace}");
// Then handle or rethrow as appropriate
}

4. Use Exception Filters for Conditional Catching

Exception filters (the when clause) let you add conditions to your catch blocks:

try
{
int value = int.Parse(inputText);
}
catch (FormatException e) when (inputText.Contains(","))
{
Debug.Log("Please use periods instead of commas for decimal points.");
}
catch (FormatException)
{
Debug.Log("Please enter a valid number.");
}

5. Log Exceptions with Sufficient Context

When logging exceptions, include enough context to understand what was happening:

try
{
LoadLevel(levelName);
}
catch (Exception e)
{
// Good: Includes context about what failed
Debug.LogError($"Failed to load level '{levelName}': {e.Message}");

// Bad: Too generic
Debug.LogError(e.Message);
}

Common try-catch Patterns in Game Development

1. Resource Loading with Fallbacks

public Texture2D LoadTexture(string texturePath)
{
try
{
return Resources.Load<Texture2D>(texturePath);
}
catch (Exception e)
{
Debug.LogWarning($"Failed to load texture '{texturePath}': {e.Message}");
return defaultTexture; // Fallback to a default texture
}
}

2. Safe Player Preferences Access

public int GetHighScore()
{
try
{
return PlayerPrefs.GetInt("HighScore", 0);
}
catch (PlayerPrefsException e)
{
Debug.LogError($"Error accessing PlayerPrefs: {e.Message}");
return 0;
}
}

3. Network Operations with Timeouts

public async Task<GameData> FetchGameData()
{
try
{
using (var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
return await apiClient.GetGameDataAsync(cancellationTokenSource.Token);
}
}
catch (OperationCanceledException)
{
Debug.LogWarning("Network request timed out. Using cached data.");
return cachedGameData;
}
catch (HttpRequestException e)
{
Debug.LogError($"Network error: {e.Message}");
return cachedGameData;
}
}

4. Safe Component Access

public void ApplyDamage(GameObject target, int amount)
{
try
{
Health healthComponent = target.GetComponent<Health>();
healthComponent.TakeDamage(amount);
}
catch (NullReferenceException)
{
Debug.LogWarning($"Target {target.name} doesn't have a Health component");
}
}

5. Parsing User Input

public void ProcessCommand(string userInput)
{
try
{
string[] parts = userInput.Split(' ');
string command = parts[0].ToLower();

switch (command)
{
case "move":
int x = int.Parse(parts[1]);
int y = int.Parse(parts[2]);
MovePlayer(x, y);
break;

case "attack":
string targetName = parts[1];
AttackTarget(targetName);
break;

default:
Debug.Log("Unknown command");
break;
}
}
catch (IndexOutOfRangeException)
{
Debug.Log("Incomplete command. Please provide all required parameters.");
}
catch (FormatException)
{
Debug.Log("Invalid parameter format. Numbers expected.");
}
catch (Exception e)
{
Debug.LogError($"Error processing command: {e.Message}");
}
}

When Not to Use try-catch

While try-catch blocks are powerful, they're not always the right tool:

1. For Control Flow

Don't use exceptions for normal program flow:

// Bad: Using exceptions for control flow
try
{
if (player.inventory.GetItemCount(itemId) > 0)
{
player.inventory.UseItem(itemId);
}
else
{
throw new Exception("No item");
}
}
catch (Exception)
{
UI.ShowMessage("You don't have that item!");
}

// Good: Using normal control flow
if (player.inventory.GetItemCount(itemId) > 0)
{
player.inventory.UseItem(itemId);
}
else
{
UI.ShowMessage("You don't have that item!");
}

2. For Expected Conditions

Don't use exceptions for conditions that are part of normal operation:

// Bad: Using exceptions for expected conditions
public bool IsHighScore(int score)
{
try
{
return score > PlayerPrefs.GetInt("HighScore");
}
catch
{
return true; // No high score saved yet
}
}

// Good: Check if the key exists
public bool IsHighScore(int score)
{
if (PlayerPrefs.HasKey("HighScore"))
{
return score > PlayerPrefs.GetInt("HighScore");
}
return true; // No high score saved yet
}

3. For Validation

Use proper validation instead of relying on exceptions:

// Bad: Relying on exceptions for validation
public void SetPlayerHealth(int health)
{
try
{
if (health < 0)
{
throw new ArgumentException("Health cannot be negative");
}
player.health = health;
}
catch (ArgumentException e)
{
Debug.LogWarning(e.Message);
player.health = 0;
}
}

// Good: Validate without exceptions
public void SetPlayerHealth(int health)
{
if (health < 0)
{
Debug.LogWarning("Health cannot be negative, setting to 0");
player.health = 0;
}
else
{
player.health = health;
}
}

Performance Considerations

Exception handling has performance implications:

  1. Creating exceptions is expensive: The runtime needs to capture the stack trace and create the exception object.
  2. Throwing exceptions is even more expensive: The runtime needs to unwind the stack and find an appropriate catch block.
  3. Caught exceptions are much cheaper than uncaught ones: An uncaught exception that crashes your program is the most expensive.

For performance-critical code (like code that runs every frame), consider alternatives to exception handling:

// Performance-sensitive code should avoid exceptions
public bool TryGetEnemyPosition(string enemyId, out Vector3 position)
{
position = Vector3.zero;

if (!enemyDictionary.ContainsKey(enemyId))
{
return false;
}

Enemy enemy = enemyDictionary[enemyId];
if (enemy == null)
{
return false;
}

position = enemy.transform.position;
return true;
}

// Usage
Vector3 enemyPos;
if (TryGetEnemyPosition("goblin_1", out enemyPos))
{
// Use enemyPos
}
else
{
// Handle the case where we couldn't get the position
}
Unity Relevance

In Unity, exception handling can be particularly expensive during gameplay, especially in methods like Update() that run every frame. Reserve try-catch blocks for operations that are not performance-critical, such as loading assets, saving game data, or processing network responses.

Conclusion

The try-catch block is your primary tool for handling exceptions in C#. By using it effectively, you can:

  • Prevent crashes that would disrupt the player experience
  • Provide graceful fallbacks when operations fail
  • Collect valuable diagnostic information for debugging
  • Make your game more robust against unexpected conditions

In the next section, we'll explore the finally block, which ensures that cleanup code runs regardless of whether an exception occurred.