One of the most common ways of localizing messages for different languages is using .NET Resource files (.resx).
Access is always done through the ResourceManager
class, regardless of whether accessor properties are generated or not; but the actual language selection is done internally by switching the current threads' Culture (implicitly,
or by passing a Culture explicitly)
A good way of dealing with those is providing accessor methods that take strongly typed parameters (to make it easier for collaborators to do the right thing) and simply retrieve/format the matching resource.
ResourceManager.GetString
returns a named resource and can be passed into string.Format
if necessary.
The Accessor method is strongly typed and has (more or less) meaningful parameter names to guide collaborators,
such as string CountryHasAnAreaOf(string countryName, long areaSizeInSquareKilometers)
(which may return something like "The Country '{0}' has an area of {1}m²"
.)
The message code is passed as a string, which is prone to typos. Starting with C#6, nameof(CurrentMethod)
can be used to avoid this (since the message code and the actual resource key are more than likely to match so they can be matched much more quickly.)
But even with nameof
, chances are that a developer copy&pastes an existing message and forgets to update the message code parameter.
To work around this potential copy&paste issue (as well as alleviating the DRY problem of putting the same name/magic string in multiple places) we'd like to use CallerMemberNameAttribute
instead, which at compile time hardcodes the current callers name. This requires the use of default parameters, which don't play very nice with params
(since it would be hard or impossible to distinguish where the params
array ends and an optional parameter begins; or whether a given positional argument should go in the params
array or the optional parameter.)
We needs some creative thinking, and put the params
part elsewhere. This approach uses a Delegate as return value which then takes the params
portion of the message.
The first call does nothing but capture the caller and wrap it into a delegate that takes a params
array. This delegate is returned and needs to be called to generate the actual message by augmenting it with format parameters.
The syntax looks weird, but given the indirection through a delegate it only takes a very miniscule performance hit compared to the Classic approach. Even assuming this is called a few thousand times in a tight loop, the runtime difference is barely hundredths of a fraction of a second across all calls.
To get rid of the double-braces when retrieving a message, we can try something different: a Delegate parameter that takes a lambda. Instead of returning a delegate, we let the caller pass in a delegate as parameter to a delegate (yes, nested!) The implementation provides the inner portion of the delegate which is the same as with the Delegate approach: capture the message code and pass it to a formatting function that also takes the format parameters. The outer portion of the delegate is provided by the caller; and does nothing else than call our inner portion with the needed format parameters. Dubbed the "Lambda" approach because the outer portion makes sense as simple lambda expression.
Looks slightly nicer than the Delegate approach, but turns out to be the slowest of the three due to double indirection. But again, we're barely talking hundredths of a fraction of a second here.
Testing was done with 10k runs in a loop after a warm-up period that isn't included in the total. Every iteration calls 4 message accessor methods in a row with the total being recorded for later.
Approach | Min | Avg | Max |
---|---|---|---|
Classic | 10.4553ms | 10.6717ms | 11.0655ms |
Delegate | 11.5051ms | 11.8092ms | 12.1331ms |
Lambda | 11.6894ms | 11.90252ms | 12.2617ms |
Times are total for all 10k iterations with 4 calls each (so 40k calls in total.) Individual results would be somewhere around 1µs for all 4 calls, or around 200-300ns for each individual call (depending on whether we need formatting or not.)
Basically, timing differences are negligible unless you got some really specific requirements with very tight timings.
The Guard
structure has no practical use other than triggering overload resolution. Assuming there is a formal string parameter that should be passed as message code, another formal string parameter describing a format parameter for the message and a third formal string parameter that is a default parameter for CallerMemberName
, passing too many strings there might overwrite the default parameter meant to receive the caller name. To avoid this, a sentinel/guard parameter is introduced which is never meant to be written out in source (as the parameter name states.) It would cause a compilation error if a string was passed in the place where this Guard
struct is placed (which either means someone is using it wrong, or we don't have enough overloads; depending on where the guard is.)