Introduction
For several years I've been building a library themed "things that should be built into the .NET framework, but aren’t". But I kept putting off writing articles about the things that should be built-in, but, you know, aren't. No longer! It's called Loyc.Essentials and you can get it via NuGet (it's named after Loyc, but that's not important.)
Loyc.Essentials has a Localize
class, which is a global hook into which a string-mapping localizer can be installed. If you’re using Loyc.Essentials anyway, you should use it. It prepares your program for translation to other languages, with virtually no effort.
The idea is to convince programmers to support localization by making it dead-easy to do. By default it is not connected to any translator (it just passes strings through), so people who are only writing a program for a one-language market can easily make their code “multiligual-ready” without doing any extra work.
All you do is call the .Localized()
extension method, which is actually shorter than writing the traditional string.Format()
. (also: using Loyc;
)
Edit: Generally speaking, this doesn't work with C# 6's interpolated strings ($"..."
) because of how C# 6 was designed, but a workaround is described at the end of this article.
The translation system itself is separate from Localize
, and connected to Localized()
by a delegate, so that multiple translation systems are possible. This class should be suitable for use in any.NET program, and some programs using this utility will want to use different localizers.
Use it like this:
string result = "Hello, {0}".Localized(userName);
Or, for increased clarity, use named placeholders:
string result = "Hello, {person's name}".Localized("person's name", userName);
Whatever localizer is installed will look up the text in its database and return a translation. If no translation to the end user’s language is available, an appropriate default translation should be returned: either the original text, or a translation to some default language, e.g. English.
The localizer will need an external table of translations, conceptually like this:
Key name | Language | Translated text |
“Hello, {0}” | “es” | “Hola, {0}” |
“Hello, {0}” | “fr” | “Bonjour, {0}” |
“Load” | “es” | “Cargar” |
“Load” | “fr” | “Charge” |
“Save” | “es” | “Guardar” |
“Save” | “fr” | “Enregistrer” |
Many developers use a resx file to store translations. This is supported as explained below.
Localizing Longer Strings
For longer messages, it is preferable to use a short name to represent the message so that, when the English text is edited, the translation tables for other languages do not have to change. To do this, use the Symbol
method:
string result = Localize.Symbol("ConfirmQuitWithoutSaving",
"Are you sure you want to quit without saving '{filename}'?", "filename", fileName);
string result = Localize.Symbol(@@ConfirmQuitWithoutSaving,
"Are you sure you want to quit without saving '{filename}'?", "filename", fileName);
This is most useful for long strings or paragraphs of text, but I expect that some projects, as a policy, will use symbols for all localizable text.
Again, you can call this method without setting up any translation table. However, the actual message is allowed to be null
. In that case, if no translator has been set up or no translation is available, Localize.Symbol
returns the symbol itself (the first argument) as a last resort.
If the variable argument list is not empty, Localize.Formatter
is called to build the completed string from the format string. It’s possible to do formatting separately - for example:
Console.WriteLine("{0} is {0:X} in hexadecimal".Localized(), N);
In this example, WriteLine
itself does the formatting, instead of Localized
.
As demonstrated above, Localize
’s default formatter, StringExt.FormatCore
, has an extra feature that the standard formatter doesn’t: named arguments. Here is an example:
...
string verb = (IsFileLoaded ? "parse" : "load").Localized();
MessageBox.Show(
"Not enough memory to {load/parse} '{filename}'.".Localized(
"load/parse", verb, "filename", FileName));
As you can see, named arguments are mentioned in the format string by specifying an argument name such as {filename}
instead of a number like {0}
. The variable argument list contains the same name followed by its value, e.g. "filename", FileName
. This feature gives you, the developer, the opportunity to tell the person writing translations what the purpose of a particular argument is.
The translator must not change any of the arguments: the word {filename}
is not to be translated.
At run-time, the format string with named arguments is converted to a “normal” format string with numbered arguments. The above example would become “Could not {1} the file: {3}” and then be passed to string.Format
.
Design rationale
Many developers don’t want to spend time writing internationalization or localization code, and are tempted to write code that is only for one language. It’s no wonder, because it’s a pain in the neck compared to hard-coding strings. Microsoft suggests that code carry around a ResourceManager
object and directly request strings from it:
private ResourceManager rm;
rm = new ResourceManager("RootNamespace.Resources", this.GetType().Assembly);
Console.Writeline(rm.GetString("StringIdentifier"));
This approach has drawbacks:
- It may be cumbersome to pass around a
ResourceManager
instance between all classes that might contain localizable strings; a global facility is much more convenient. - The programmer has to put all translations in the resource file; consequently, writing the code is bothersome because the programmer has to switch to the resource file and add the string to it. Someone reading the code, in turn, can’t tell what the string says and has to load up the resource file to find out.
- It is not easy to change the localization manager; for instance, what if someone wants to store translations in an.ini, .xml or.les file rather than inside the assembly? What if the user wants to centralize all translations for a set of assemblies, rather than having separate resources in each assembly?
- GetString returns
null
if the requested identifier was not found, potentially leading to blank output or a NullReferenceException
.
Microsoft does address the first of these drawbacks by providing a code generator built into Visual Studio that gives you a global property for each string; see here.
Even so, you may find that this class provides a more convenient approach because your native-language strings are written right in your code, and because you are guaranteed to get a string at runtime (not null) if the desired language is not available.
Combining with ResourceManager
This class supports ResourceManager via the UseResourceManager
helper method. For example, after calling Localize.UseResourceManager(resourceManager)
, if you write
Then resourceManager.GetString("Save As...")
is called to get the translated string, or the original string if no translation was found (and yes, in your resx file you can use spaces and punctuation in the left-hand side). You can even add a “name calculator” to encode your resx file’s naming convention, e.g. by removing spaces and punctuation (for details, look at the UseResourceManager
method.)
It is common in .NET programs to have one “main” resx file, e.g. Resources.resx, that contains default strings, along other files with non-English translations (e.g. Resources.es.resx for Spanish). When using Localized()
you might use a slightly different approach: you still create a Resources.resx file for your project, but you leave the string table empty (you can still use it for other resources, such as icons). This causes Visual Studio to generate a Resources
class with a ResourceManager
property so that you need can easily get the instance of ResourceManager
that you need.
- When your program starts, call
Localize.UseResourceManager(Resources.ResourceManager)
. - Use the
Localized()
extension method to get translations of short strings. - For long strings, use
Localize.Symbol("ShortAlias", "Long string", params...)
. The first argument is the string passed to ResourceManager.GetString()
Localization with string interpolation
It is possible to combine localization with C# 6 interpolated strings, like in $"this string {...}"
(and thanks to Florian Rappl for drawing this to my attention.)
Unfortunately, Localize()
does not work with them.
Initially I thought it wasn't possible at all, because normally string interpolation is translated to string.Format
, whose behavior cannot be customized. However, in much the same way as lambda methods sometimes become expression trees, the compiler will switch from string.Format
to FormattableStringFactory.Create
(a .NET 4.6 method) if the target method accepts a System.FormattableString
object.
The problem is, the compiler prefers to call string.Format
if possible, so if there were an overload of Localized()
that accepted FormattableString
, it would not work with string interpolation because the C# compiler would simply ignore it (since Localized()
can already accept a string). Actually, it's worse than that: the compiler also refuses to use FormattableString
when calling an extension method.
It can work if you use a non-extension method. For example:
static class Loca
{
public static string lize(this FormattableString message)
{ return message.Format.Localized(message.GetArguments()); }
}
Then you can use it like this:
public class Program
{
public static void Main(string[] args)
{
Localize.UseResourceManager(Resources.ResourceManager);
var name = "Dave";
Console.WriteLine(Loca.lize($"Hello, {name}"));
}
}
It's important to realize that the compiler converts the $"..."
string into an old-fashioned format string. So in this example, Loca.lize
actually receives "Hello, {0}"
as the format string, not "Hello, {name}"
.
Unfortunately, it is a bit confusing that we need a completely different way of localizing interpolated strings compared to normal strings, and if you forget—if you write $"Hello, {name}".Localized()
—your code will be broken, because formatting will occur before localization and therefore no translation will be found.
To avoid this confusion, I am not planning to extend my library to support string interpolation, but if you do prefer to use string interpolation in your app, you can still localize it by adding a helper method like Loca.lize
to your project.
Source code
The source code is here. Unfortunately, it does use some types that are specific to Loyc.Essentials (Symbol
, ThreadLocalVariable<T>
, SavedValue<T>
and ScratchBuffer<T>
), so if you want to use Localize
without the Loyc.Essentials NuGet package, you'll have to take some time to convert it down to "plain-old" C#.