Click here to Skip to main content
14,388,066 members

Prepare all your apps for localization

Rate this:
4.74 (9 votes)
Please Sign up or sign in to vote.
4.74 (9 votes)
2 Feb 2019CPOL
No matter how lazy you are

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:

// The translation table will be searched for "ConfirmQuitWithoutSaving"
string result = Localize.Symbol("ConfirmQuitWithoutSaving",
    "Are you sure you want to quit without saving '{filename}'?", "filename", fileName);

// Enhanced C# syntax with symbol literal
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

"Save As...".Localized()

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 (SymbolThreadLocalVariable<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#.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Qwertie
Software Developer None
Canada Canada
Since I started programming when I was 11, I wrote the SNES emulator "SNEqr", the FastNav mapping component, the Enhanced C# programming language (in progress), the parser generator LLLPG, and LES, a syntax to help you start building programming languages, DSLs or build systems.

My overall focus is on the Language of your choice (Loyc) initiative, which is about investigating ways to improve interoperability between programming languages and putting more power in the hands of developers. I'm also seeking employment.

Comments and Discussions

 
QuestionNice Pin
MikeTheFid16-Jan-17 7:51
MemberMikeTheFid16-Jan-17 7:51 
QuestionInterpolated string Pin
Florian Rappl13-Jan-17 23:52
professionalFlorian Rappl13-Jan-17 23:52 
AnswerRe: Interpolated string Pin
Qwertie14-Jan-17 0:29
MemberQwertie14-Jan-17 0:29 
GeneralRe: Interpolated string Pin
Florian Rappl14-Jan-17 10:07
professionalFlorian Rappl14-Jan-17 10:07 
GeneralRe: Interpolated string Pin
Qwertie15-Jan-17 14:07
MemberQwertie15-Jan-17 14:07 
GeneralRe: Interpolated string Pin
Qwertie15-Jan-17 14:17
MemberQwertie15-Jan-17 14:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 13 Jan 2017

Stats

15K views
22 bookmarked