Understanding Constants and Readonly Fields in C#: A Practical Guide
When working with C#, developers often encounter situations where they need to define values that shouldn’t change during program execution. Two primary mechanisms for this are const and readonly fields. While they might seem similar at first glance, understanding their differences is crucial for writing robust, maintainable code. In this post, we’ll explore both approaches, their appropriate use cases, and best practices that can help us make better design decisions.
The Fundamental Distinction
At their core, const and readonly serve different purposes in the C# ecosystem:
constrepresents compile-time constants - values that are determined when we write our code and baked directly into the compiled assemblyreadonlyrepresents runtime constants - values that are set during object construction or initialization and remain immutable thereafter
Let’s examine how this fundamental difference manifests in our daily coding practices.
Compile-Time vs. Runtime: A Practical Example
Consider a scenario where we’re building a financial application. We might define some values that are truly constant and others that depend on runtime configuration:
public class FinancialCalculator
{
// These are compile-time constants - they'll never change
public const decimal TaxRate = 0.08m; // 8% sales tax
public const int DaysInWeek = 7;
public const string CurrencyCode = "USD";
// These are runtime values - they're set when objects are created
public readonly decimal MinimumPayment;
public readonly decimal TransactionFee;
public readonly DateTime CalculationDate;
public FinancialCalculator(decimal minPayment, decimal fee)
{
MinimumPayment = minPayment; // Set from constructor parameter
TransactionFee = fee; // Set from constructor parameter
CalculationDate = DateTime.Now; // Set at runtime
}
public decimal CalculateTax(decimal amount)
{
// The const value is inlined here at compile time
return amount * TaxRate;
}
}
In this example, TaxRate is a true constant - it’s defined by law and won’t change unless the tax code changes (which would require recompilation anyway). The MinimumPayment and TransactionFee, however, might vary based on customer agreements or business rules, making them perfect candidates for readonly fields.
Versioning Considerations: The Hidden Cost of const
One of the most important yet often overlooked aspects of const is how it affects assembly versioning. When we use a const field, its value gets copied into every assembly that references it. This can lead to subtle bugs when updating libraries.
Let’s look at a common scenario:
// In SharedLibrary.dll v1.0
public class AppConstants
{
public const string AppVersion = "1.0";
public static readonly string Environment = "Production";
}
// In OurApplication.exe (references SharedLibrary v1.0)
public class Program
{
public void Run()
{
// This value is BAKED INTO OurApplication.exe
Console.WriteLine(AppConstants.AppVersion); // Outputs "1.0"
// This value is LOOKED UP at runtime from SharedLibrary.dll
Console.WriteLine(AppConstants.Environment); // Outputs "Production"
}
}
Now, if we update SharedLibrary.dll to v2.0 and change the constants:
// In SharedLibrary.dll v2.0
public class AppConstants
{
public const string AppVersion = "2.0"; // Changed from "1.0"
public static readonly string Environment = "Staging"; // Changed from "Production"
}
Here’s what happens:
- OurApplication.exe still shows “1.0” for
AppVersionbecause that value was baked in during compilation - OurApplication.exe shows “Staging” for
Environmentbecause it reads the current value at runtime
This difference becomes crucial when we’re distributing libraries or building modular applications. The readonly approach provides more flexibility in deployment scenarios.
Type Limitations and Flexibility
The types we can use with const are quite limited, which influences our design decisions:
// ✅ Allowed with const (compile-time constants)
const int MaxUsers = 100;
const string CompanyName = "TechCorp";
const double Pi = 3.14159;
const bool IsEnabled = true;
const DayOfWeek FirstDay = DayOfWeek.Monday;
// ❌ NOT allowed with const (must use readonly instead)
// const DateTime Created = DateTime.Now; // Requires runtime computation
// const List<string> Items = new(); // Reference type
// const HttpClient Client = new(); // Reference type
// const decimal? NullablePrice = 99.99m; // Nullable types
In contrast, readonly accepts any type, giving us much more flexibility:
// All perfectly valid with readonly
readonly DateTime Created = DateTime.Now;
readonly List<string> Items = new();
readonly HttpClient Client;
readonly ILogger Logger;
readonly Dictionary<int, string> Cache = new();
readonly int[] Numbers = { 1, 2, 3 };
Performance Implications in Real-World Scenarios
While the performance difference between const and readonly is usually negligible, there are scenarios where it matters. const values are inlined by the compiler, eliminating field lookups at runtime:
public class PerformanceSensitiveCode
{
// These const values get inlined
private const int BufferSize = 4096; // Inlined everywhere it's used
private const double ScalingFactor = 1.5; // Inlined everywhere it's used
// These readonly fields require memory access
private readonly int MaxConnections; // Field lookup at runtime
private static readonly double BaseRate; // Static field lookup at runtime
public void ProcessData()
{
byte[] buffer = new byte[BufferSize]; // Compiles to: new byte[4096]
// The const value doesn't exist as a field at runtime
// It's literally replaced with 4096 in the compiled code
}
}
In high-performance loops or mathematical calculations, using const can provide a slight performance advantage. However, we should always prioritize correctness and maintainability over micro-optimizations.
Common Patterns and Best Practices
1. Dependency Injection with readonly
This is perhaps the most common use case for readonly fields in modern C# applications:
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<OrderService> _logger;
private readonly IEmailService _emailService;
public OrderService(
IOrderRepository orderRepository,
ILogger<OrderService> logger,
IEmailService emailService)
{
// readonly ensures these dependencies can't be accidentally reassigned
_orderRepository = orderRepository ??
throw new ArgumentNullException(nameof(orderRepository));
_logger = logger ??
throw new ArgumentNullException(nameof(logger));
_emailService = emailService ??
throw new ArgumentNullException(nameof(emailService));
}
public async Task ProcessOrder(Order order)
{
// We can be confident that _orderRepository won't be null
// (assuming it was properly validated in the constructor)
await _orderRepository.SaveAsync(order);
}
}
2. Configuration Settings
For application settings that should be immutable after initialization:
public class AppSettings
{
// Use const for truly universal constants
public const string AppName = "InventoryManager";
public const int DefaultPageSize = 50;
// Use readonly for settings that come from configuration
public readonly string ConnectionString;
public readonly TimeSpan CacheDuration;
public readonly bool EnableLogging;
public AppSettings(IConfiguration configuration)
{
ConnectionString = configuration.GetConnectionString("Default");
CacheDuration = TimeSpan.FromMinutes(
configuration.GetValue<int>("CacheDurationMinutes", 30));
EnableLogging = configuration.GetValue<bool>("EnableLogging", true);
}
}
3. Thread-Safe Initialization
When dealing with lazy initialization in multi-threaded scenarios:
public class ThreadSafeCache
{
private readonly object _lockObject = new();
private readonly Lazy<Dictionary<string, object>> _lazyCache;
private readonly TimeSpan _cacheTimeout;
public ThreadSafeCache(TimeSpan timeout)
{
_cacheTimeout = timeout;
_lazyCache = new Lazy<Dictionary<string, object>>(
() => new Dictionary<string, object>(),
LazyThreadSafetyMode.ExecutionAndPublication);
}
public void AddItem(string key, object value)
{
lock (_lockObject)
{
_lazyCache.Value[key] = value;
}
}
}
4. When to Use Each: A Decision Guide
Here’s a practical decision matrix we can use:
| Scenario | Recommendation | Reasoning |
|---|---|---|
| Mathematical constants | Use const |
Values like π or e never change |
| App version number | Use readonly or static readonly |
Allows updates without recompilation |
| Dependency injection | Use readonly |
Prevents accidental reassignment |
| Configuration values | Use readonly |
Set at runtime from config files |
| Attribute parameters | Must use const |
Attributes require compile-time constants |
| Enum-like simple values | Use const |
When you need simple named values |
| Collections | Use readonly |
Can’t use const with reference types |
The Immutability Misconception
A common point of confusion is thinking that readonly makes objects immutable. It doesn’t - it only prevents reassignment of the reference:
public class Example
{
// readonly prevents reassignment, not modification
public readonly List<string> Items = new();
public void Demonstrate()
{
// Items = new List<string>(); // ❌ Compile error: can't reassign
Items.Add("New Item"); // ✅ Perfectly valid
Items.RemoveAt(0); // ✅ Perfectly valid
Items.Clear(); // ✅ Perfectly valid
}
}
For true immutability, we need to combine readonly with immutable collections or interfaces:
public class ImmutableExample
{
// Truly immutable collection
public readonly IReadOnlyList<string> Items;
public ImmutableExample(IEnumerable<string> initialItems)
{
// Create a defensive copy and wrap it
Items = new List<string>(initialItems).AsReadOnly();
}
// Now Items cannot be modified at all
}
Modern C#: The Evolving Landscape
With newer versions of C#, we have additional options. C# 9 introduced init-only properties, and C# 12 brings primary constructors, both offering alternative approaches to immutability:
// Using init-only properties (C# 9+)
public class ModernSettings
{
public string ConnectionString { get; init; }
public int TimeoutSeconds { get; init; }
}
// Using primary constructors (C# 12+)
public class UserService(IUserRepository repository, ILogger logger)
{
// Parameters become private fields automatically
public User GetUser(int id) => repository.Get(id);
}
Conclusion: Practical Recommendations
After examining both approaches, here are our practical recommendations:
- Default to
readonlyfor most fields that shouldn’t change - it’s more flexible and version-safe - Use
constonly for values that are truly universal and will never change - Be cautious with public
constvalues in libraries - they create tight coupling between assemblies - Consider
static readonlyfor shared values that need runtime initialization - Remember that
readonlydoesn’t guarantee immutability - it only prevents reassignment - Use the right tool for the job based on versioning needs, type requirements, and initialization timing
By understanding these distinctions and applying them thoughtfully, we can write more maintainable, version-safe C# code that stands the test of time. The key is recognizing that const and readonly are different tools for different jobs, and choosing the appropriate one based on our specific needs rather than habit or convenience.
Leave a comment