Advanced C# Features: LINQ and Async/Await
As you progress in C# programming, you'll encounter powerful features that make your code more elegant, efficient, and maintainable. LINQ (Language Integrated Query) allows you to query and manipulate data collections with a clean, readable syntax. Async/await enables you to write asynchronous code that doesn't block your game's main thread, keeping your games responsive and smooth.
In this chapter, you'll learn how to use LINQ to filter, transform, and query collections, and how to use async/await to handle asynchronous operations like loading files, making network requests, and performing time-consuming tasks without freezing your game.
What You'll Learn
- LINQ Basics - Query collections with a SQL-like syntax
- LINQ Methods - Filter, map, aggregate, and transform data
- Async/Await Fundamentals - Write non-blocking asynchronous code
- Task-Based Asynchronous Programming - Handle async operations properly
- Unity Coroutines vs Async/Await - When to use each approach
- Real-World Applications - Loading assets, network requests, and file operations
- Best Practices - Avoid common pitfalls and write efficient async code
Prerequisites
- Completed File I/O and Data Persistence
- Understanding of collections (arrays, lists, dictionaries)
- Basic knowledge of methods and classes
- Familiarity with Unity (optional, but helpful for examples)
Understanding LINQ
LINQ (Language Integrated Query) is a powerful feature that lets you query collections of data using a syntax similar to SQL. Instead of writing loops and conditionals, you can express data operations declaratively, making your code more readable and maintainable.
Real-World Analogy: Think of LINQ like a smart filter for a library. Instead of manually checking each book, you can say "show me all books published after 2020 that are about game development" and LINQ finds them for you automatically.
Why Use LINQ?
LINQ provides several benefits:
- Readability - Code reads like natural language
- Less Code - Fewer lines than traditional loops
- Type Safety - Compile-time checking prevents errors
- Performance - Optimized query execution
- Consistency - Same syntax works with different data sources
Basic LINQ Syntax
LINQ offers two syntax styles: query syntax and method syntax. Both accomplish the same thing, but method syntax is more commonly used in modern C# code.
using System.Linq;
// Method syntax (most common)
var result = numbers.Where(n => n > 5).ToList();
// Query syntax (SQL-like)
var result = from n in numbers
where n > 5
select n;
LINQ Methods for Collections
LINQ provides dozens of methods for working with collections. Here are the most important ones for game development:
Filtering: Where()
The Where() method filters a collection based on a condition, returning only items that match.
using System.Linq;
using System.Collections.Generic;
List<int> scores = new List<int> { 100, 85, 92, 78, 95, 88 };
// Get scores above 90
var highScores = scores.Where(score => score > 90).ToList();
// Result: [100, 92, 95]
// Get even scores
var evenScores = scores.Where(score => score % 2 == 0).ToList();
// Result: [100, 92, 78, 88]
Game Development Example:
public class Enemy
{
public string Name { get; set; }
public int Health { get; set; }
public bool IsAlive => Health > 0;
}
List<Enemy> enemies = new List<Enemy>
{
new Enemy { Name = "Goblin", Health = 50 },
new Enemy { Name = "Orc", Health = 0 },
new Enemy { Name = "Dragon", Health = 200 }
};
// Get only alive enemies
var aliveEnemies = enemies.Where(e => e.IsAlive).ToList();
Transforming: Select()
The Select() method transforms each element in a collection, creating a new collection with transformed values.
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
// Multiply each number by 2
var doubled = numbers.Select(n => n * 2).ToList();
// Result: [2, 4, 6, 8, 10]
// Convert to strings
var strings = numbers.Select(n => n.ToString()).ToList();
// Result: ["1", "2", "3", "4", "5"]
Game Development Example:
public class Item
{
public string Name { get; set; }
public int Price { get; set; }
}
List<Item> items = new List<Item>
{
new Item { Name = "Sword", Price = 100 },
new Item { Name = "Shield", Price = 75 },
new Item { Name = "Potion", Price = 25 }
};
// Get only item names
var itemNames = items.Select(item => item.Name).ToList();
// Result: ["Sword", "Shield", "Potion"]
// Get prices with tax (10%)
var pricesWithTax = items.Select(item => item.Price * 1.1f).ToList();
Aggregating: Sum(), Average(), Min(), Max()
These methods calculate aggregate values from collections.
List<int> scores = new List<int> { 100, 85, 92, 78, 95 };
int total = scores.Sum(); // 450
double average = scores.Average(); // 90.0
int min = scores.Min(); // 78
int max = scores.Max(); // 100
Game Development Example:
public class Player
{
public int Gold { get; set; }
public int Level { get; set; }
}
List<Player> players = new List<Player>
{
new Player { Gold = 1000, Level = 5 },
new Player { Gold = 500, Level = 3 },
new Player { Gold = 2000, Level = 8 }
};
// Calculate total gold across all players
int totalGold = players.Sum(p => p.Gold); // 3500
// Find highest level
int maxLevel = players.Max(p => p.Level); // 8
// Calculate average gold
double avgGold = players.Average(p => p.Gold); // 1166.67
Finding Elements: First(), Last(), Single()
These methods find specific elements in a collection.
List<int> numbers = new List<int> { 10, 20, 30, 40, 50 };
int first = numbers.First(); // 10
int last = numbers.Last(); // 50
int firstEven = numbers.First(n => n % 2 == 0); // 10
// Single() throws exception if more than one match
int single = numbers.Single(n => n == 30); // 30
Important: Single() throws an exception if zero or more than one element matches. Use FirstOrDefault() or SingleOrDefault() to avoid exceptions.
// Returns default value (0 for int) if not found
int firstOrDefault = numbers.FirstOrDefault(n => n > 100); // 0
// Returns null for reference types
string name = names.FirstOrDefault(n => n.StartsWith("Z")); // null
Ordering: OrderBy(), OrderByDescending()
Sort collections based on a key.
List<Player> players = new List<Player>
{
new Player { Name = "GamineAI Team", Score = 1000 },
new Player { Name = "Bob", Score = 1500 },
new Player { Name = "Charlie", Score = 800 }
};
// Sort by score (ascending)
var sortedByScore = players.OrderBy(p => p.Score).ToList();
// Sort by score (descending)
var sortedByScoreDesc = players.OrderByDescending(p => p.Score).ToList();
// Sort by name
var sortedByName = players.OrderBy(p => p.Name).ToList();
Grouping: GroupBy()
Group elements by a key.
List<Item> items = new List<Item>
{
new Item { Name = "Sword", Type = "Weapon", Price = 100 },
new Item { Name = "Shield", Type = "Armor", Price = 75 },
new Item { Name = "Axe", Type = "Weapon", Price = 90 },
new Item { Name = "Helmet", Type = "Armor", Price = 50 }
};
// Group by type
var groupedByType = items.GroupBy(item => item.Type);
foreach (var group in groupedByType)
{
Console.WriteLine($"Type: {group.Key}");
foreach (var item in group)
{
Console.WriteLine($" - {item.Name}: {item.Price}");
}
}
// Output:
// Type: Weapon
// - Sword: 100
// - Axe: 90
// Type: Armor
// - Shield: 75
// - Helmet: 50
Chaining LINQ Methods
You can chain multiple LINQ methods together for complex queries.
List<Player> players = new List<Player>
{
new Player { Name = "GamineAI Team", Score = 1000, Level = 5 },
new Player { Name = "Bob", Score = 1500, Level = 8 },
new Player { Name = "Charlie", Score = 800, Level = 3 },
new Player { Name = "Diana", Score = 1200, Level = 6 }
};
// Get names of players with score > 1000, sorted by level
var result = players
.Where(p => p.Score > 1000)
.OrderBy(p => p.Level)
.Select(p => p.Name)
.ToList();
// Result: ["Diana", "Bob"]
Understanding Async/Await
Asynchronous programming allows your code to perform long-running operations without blocking the main thread. This is crucial in game development where you need to load assets, make network requests, or perform calculations without freezing the game.
Real-World Analogy: Think of async/await like ordering food at a restaurant. You place your order (start async operation), then you can chat with friends (continue other work) while waiting. When the food arrives (async operation completes), you eat it (handle the result). You didn't stand still waiting for the food.
Why Use Async/Await?
- Non-Blocking - Game stays responsive during long operations
- Better Performance - CPU can do other work while waiting
- Cleaner Code - Easier to read than callbacks or coroutines
- Error Handling - Use try-catch with async operations
- Composable - Chain async operations easily
Basic Async/Await Syntax
using System.Threading.Tasks;
// Mark method as async
async Task LoadDataAsync()
{
// Use await to wait for async operation
string data = await File.ReadAllTextAsync("data.txt");
Console.WriteLine(data);
}
// Call async method
await LoadDataAsync();
Task and Task
Task- Represents an async operation that doesn't return a valueTask<T>- Represents an async operation that returns a value of type T
// Task (no return value)
async Task SaveDataAsync()
{
await File.WriteAllTextAsync("data.txt", "Hello");
}
// Task<string> (returns string)
async Task<string> LoadDataAsync()
{
return await File.ReadAllTextAsync("data.txt");
}
// Usage
await SaveDataAsync();
string data = await LoadDataAsync();
Async Methods in Unity
Unity supports async/await, but you need to be careful about threading. Unity's API must be called from the main thread.
using UnityEngine;
using System.Threading.Tasks;
using System.IO;
public class DataLoader : MonoBehaviour
{
async void Start()
{
// Load data asynchronously
string data = await LoadFileAsync("gameData.json");
// Process data on main thread
Debug.Log($"Loaded: {data}");
}
async Task<string> LoadFileAsync(string filename)
{
// File operations can be async
string path = Path.Combine(Application.persistentDataPath, filename);
if (File.Exists(path))
{
return await File.ReadAllTextAsync(path);
}
return null;
}
}
Error Handling with Async/Await
Use try-catch blocks with async methods just like synchronous code.
async Task LoadDataSafelyAsync()
{
try
{
string data = await File.ReadAllTextAsync("data.txt");
Console.WriteLine(data);
}
catch (FileNotFoundException)
{
Console.WriteLine("File not found!");
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
Combining Multiple Async Operations
You can await multiple async operations sequentially or in parallel.
Sequential (one after another):
async Task LoadMultipleFilesAsync()
{
string file1 = await File.ReadAllTextAsync("file1.txt");
string file2 = await File.ReadAllTextAsync("file2.txt");
string file3 = await File.ReadAllTextAsync("file3.txt");
// Process files...
}
Parallel (all at once):
async Task LoadMultipleFilesParallelAsync()
{
Task<string> task1 = File.ReadAllTextAsync("file1.txt");
Task<string> task2 = File.ReadAllTextAsync("file2.txt");
Task<string> task3 = File.ReadAllTextAsync("file3.txt");
// Wait for all to complete
await Task.WhenAll(task1, task2, task3);
string file1 = await task1;
string file2 = await task2;
string file3 = await task3;
// Process files...
}
Real-World Game Development Examples
Example 1: Loading Player Data
using UnityEngine;
using System.Threading.Tasks;
using System.IO;
using Newtonsoft.Json;
[System.Serializable]
public class PlayerData
{
public string playerName;
public int level;
public int experience;
public int gold;
}
public class PlayerDataManager : MonoBehaviour
{
private string savePath;
void Start()
{
savePath = Path.Combine(Application.persistentDataPath, "playerData.json");
}
public async Task<PlayerData> LoadPlayerDataAsync()
{
try
{
if (File.Exists(savePath))
{
string json = await File.ReadAllTextAsync(savePath);
return JsonConvert.DeserializeObject<PlayerData>(json);
}
}
catch (Exception ex)
{
Debug.LogError($"Error loading player data: {ex.Message}");
}
return new PlayerData(); // Return default data
}
public async Task SavePlayerDataAsync(PlayerData data)
{
try
{
string json = JsonConvert.SerializeObject(data, Formatting.Indented);
await File.WriteAllTextAsync(savePath, json);
Debug.Log("Player data saved successfully!");
}
catch (Exception ex)
{
Debug.LogError($"Error saving player data: {ex.Message}");
}
}
}
Example 2: Filtering Enemy Lists with LINQ
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
private List<Enemy> enemies = new List<Enemy>();
// Get all enemies within range
public List<Enemy> GetEnemiesInRange(Vector3 position, float range)
{
return enemies
.Where(e => Vector3.Distance(e.transform.position, position) <= range)
.Where(e => e.IsAlive)
.OrderBy(e => Vector3.Distance(e.transform.position, position))
.ToList();
}
// Get strongest enemy
public Enemy GetStrongestEnemy()
{
return enemies
.Where(e => e.IsAlive)
.OrderByDescending(e => e.Health)
.FirstOrDefault();
}
// Get average enemy health
public float GetAverageEnemyHealth()
{
return enemies
.Where(e => e.IsAlive)
.Average(e => e.Health);
}
// Group enemies by type
public Dictionary<string, List<Enemy>> GetEnemiesByType()
{
return enemies
.Where(e => e.IsAlive)
.GroupBy(e => e.Type)
.ToDictionary(g => g.Key, g => g.ToList());
}
}
Example 3: Loading Multiple Assets Asynchronously
using UnityEngine;
using System.Threading.Tasks;
using System.Collections.Generic;
public class AssetLoader : MonoBehaviour
{
public async Task LoadAllAssetsAsync()
{
List<Task> loadTasks = new List<Task>();
// Load multiple assets in parallel
loadTasks.Add(LoadTextureAsync("texture1.png"));
loadTasks.Add(LoadTextureAsync("texture2.png"));
loadTasks.Add(LoadAudioAsync("music.mp3"));
loadTasks.Add(LoadDataAsync("config.json"));
// Wait for all to complete
await Task.WhenAll(loadTasks);
Debug.Log("All assets loaded!");
}
async Task LoadTextureAsync(string path)
{
// Simulate async texture loading
await Task.Delay(1000); // Simulate network delay
Debug.Log($"Loaded texture: {path}");
}
async Task LoadAudioAsync(string path)
{
await Task.Delay(500);
Debug.Log($"Loaded audio: {path}");
}
async Task LoadDataAsync(string path)
{
await Task.Delay(200);
Debug.Log($"Loaded data: {path}");
}
}
Unity Coroutines vs Async/Await
Unity has its own system for asynchronous operations called coroutines. Here's when to use each:
Use Coroutines When:
- Working with Unity's
yieldinstructions (WaitForSeconds,WaitForEndOfFrame) - Need to pause execution for specific Unity events
- Working with Unity's animation system
- Need to run code over multiple frames
Use Async/Await When:
- Loading files or making network requests
- Performing CPU-intensive calculations
- Working with Task-based APIs
- Need better error handling with try-catch
- Want to chain multiple async operations
Example Comparison:
// Coroutine approach
IEnumerator LoadDataCoroutine()
{
yield return new WaitForSeconds(1f);
// Load data...
yield return null;
}
// Async/await approach
async Task LoadDataAsync()
{
await Task.Delay(1000);
// Load data...
}
Common Pitfalls and Best Practices
1. Don't Forget await
// WRONG - Fire and forget (no error handling)
LoadDataAsync(); // Missing await!
// CORRECT
await LoadDataAsync();
2. Avoid async void
// WRONG - Can't catch exceptions
async void BadMethod()
{
throw new Exception("Error!");
}
// CORRECT - Use async Task
async Task GoodMethod()
{
throw new Exception("Error!");
}
3. ConfigureAwait(false) for Library Code
// In library code, use ConfigureAwait(false) to avoid deadlocks
public async Task<string> LoadDataAsync()
{
return await File.ReadAllTextAsync("data.txt").ConfigureAwait(false);
}
4. Don't Block Async Code
// WRONG - Blocks the thread
var data = LoadDataAsync().Result; // Don't do this!
// CORRECT - Use await
var data = await LoadDataAsync();
5. Use LINQ Efficiently
// WRONG - Multiple iterations
var highScores = scores.Where(s => s > 90).ToList();
var lowScores = scores.Where(s => s < 50).ToList();
// CORRECT - Single iteration with grouping
var grouped = scores.GroupBy(s => s > 90 ? "High" : s < 50 ? "Low" : "Medium");
Practice Exercises
Exercise 1: LINQ Query
Create a list of Item objects with Name, Price, and Category properties. Write LINQ queries to:
- Find all items under $50
- Get the total value of all items
- Group items by category
- Find the most expensive item
Exercise 2: Async File Loading
Create an async method that loads a JSON file, parses it, and returns the data. Include proper error handling.
Exercise 3: Parallel Loading
Create a method that loads three different files in parallel and processes them when all are complete.
Pro Tips
Tip 1: Use LINQ for Readability
LINQ makes your code more readable than traditional loops. Use it when the query is complex or when you want to express intent clearly.
Tip 2: Profile Async Performance
Async operations have overhead. Use them for I/O-bound operations (file, network) but be careful with CPU-bound tasks.
Tip 3: Combine LINQ and Async
You can use LINQ with async collections using System.Linq.Async or by converting to lists first.
Tip 4: Cache LINQ Results
If you're using a LINQ query multiple times, call .ToList() or .ToArray() to cache the results.
Troubleshooting
Problem: "Cannot await non-async method"
Solution: Make sure the method is marked with async and returns Task or Task<T>.
Problem: LINQ query is slow
Solution: Use .ToList() or .ToArray() to materialize the query, or optimize the query itself.
Problem: Async method never completes
Solution: Check for deadlocks, ensure you're using await properly, and verify the async operation actually completes.
Summary
In this chapter, you've learned:
- LINQ provides a powerful, readable way to query and manipulate collections
- Async/await enables non-blocking asynchronous operations
- LINQ methods like
Where(),Select(),OrderBy(), andGroupBy()make data manipulation easy - Task-based async allows you to handle long-running operations without blocking
- Unity integration works with both coroutines and async/await, each with their own use cases
- Best practices help you avoid common pitfalls and write efficient code
These advanced features will make your C# code more modern, efficient, and maintainable. Practice using LINQ for data queries and async/await for I/O operations, and you'll write professional-quality game code.
Next Steps
- Performance Optimization and Memory Management → - Learn how to write efficient C# code and manage memory properly
- Review LINQ documentation for more methods
- Practice async/await with file operations
- Experiment with combining LINQ and async operations
Ready to optimize your code? Continue to the next chapter to learn about performance optimization and memory management in C#!