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:
- Attempt to execute code that might cause an exception (
try
) - Catch and handle any exceptions that occur (
catch
) - 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:
- Multiple catch blocks: We can have multiple
catch
blocks to handle different types of exceptions differently. - Exception hierarchy: The order of
catch
blocks matters. More specific exceptions should come before more general ones. - Graceful degradation: Instead of crashing, we provide fallback behavior (creating new player data).
- 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:
- Creating exceptions is expensive: The runtime needs to capture the stack trace and create the exception object.
- Throwing exceptions is even more expensive: The runtime needs to unwind the stack and find an appropriate catch block.
- 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
}
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.