12
\$\begingroup\$

Our company needs a localization/translation behavior which allows incomplete (ResX) resources. If a String

  1. isn't available in italian
  2. fall back to the next roman language, like french
  3. fall back to our invariant (in this case: german)

The easiest approach was a custom CultureInfo.

/// <summary>
/// A <see cref="CultureInfo" /> which switches to another language instead of  <see cref="CultureInfo.InvariantCulture" />.
/// </summary>
public class FallbackCultureInfo : CultureInfo
{
    private static readonly List<FallbackCultureInfo> CultureInfos = new List<FallbackCultureInfo>();
    private readonly CultureInfo fallback;
    private CultureInfo determinedParent;

    public override CultureInfo Parent
    {
        get
        {
            if (this.determinedParent != null)
            {
                return this.determinedParent;
            }

            var originalParent = base.Parent;
            if (Object.Equals(originalParent, CultureInfo.InvariantCulture) && (this.fallback != null))
            {
                return this.determinedParent = this.fallback;
            }

            if (this.fallback == null)
            {
                return this.determinedParent = originalParent;
            }

            this.determinedParent = FallbackCultureInfo.Build(originalParent.Name, this.fallback.Name);
            return this.determinedParent;
        }
    }


    private FallbackCultureInfo(String name, CultureInfo fallback = null) : base(name)
    {
        this.fallback = fallback;
    }

    /// <summary>
    /// Builds a <see cref="CultureInfo"/> with a custom fallback behavior, which switsches to 
    /// another language before it gets <see cref="CultureInfo.InvariantCulture"/>.
    /// </summary>
    /// <example>
    /// CultureInfo.CurrentUICulture = FallbackCultureInfo.Build("it-CH", "fr-CH", "de-CH");
    /// </example>
    /// <remarks>
    /// Due to a missing <see cref="CultureInfo" />.operator== we have to ensure a unique instance per name on our own.
    /// </remarks>
    /// <param name="name">Name of our culture, like "en-US"</param>
    /// <param name="fallbacks">Fallback stack, like "en-GB", "fr-FR"</param>
    /// <returns>The build <see cref="CultureInfo"/>.</returns>
    public static FallbackCultureInfo Build(String name, params String[] fallbacks)
    {
        lock (FallbackCultureInfo.CultureInfos)
        {
            return FallbackCultureInfo.QueuedBuild(new[] { name }.Concat(fallbacks).Reverse());
        }
    }

    private static FallbackCultureInfo QueuedBuild(IEnumerable<String> names)
    {
        FallbackCultureInfo result = null;
        FallbackCultureInfo lastFallback = null;
        foreach (var name in names)
        {
            result = FallbackCultureInfo.CultureInfos.FirstOrDefault(ci => String.Equals(ci.Name, name));
            if (result != null)
            {
                lastFallback = result;
                continue;
            }

            result = lastFallback = new FallbackCultureInfo(name, lastFallback);
            FallbackCultureInfo.CultureInfos.Add(result);
        }

        return result;
    }
}

CultureInfo.CurrentUICulture = FallbackCultureInfo.Build("it-CH", "fr-CH", "de-CH");

Works like a charm at a first glance.

But because it is located at a general .Net position, I would like to ask for other opinions about this way to solve the problem. Has anyone done a similiar approach which failed somehow - or even if you havent - do so see any issues with it?

\$\endgroup\$
5
  • 4
    \$\begingroup\$ One issue I can see happening, that can't quite be mitigated in code, is that "if a string isn't available in culture X", it means the .resx file is literally missing the resource key for that string - and whether a resource key is present or not in a .resx file pretty much depends on your localization workflow. Finding missing keys in a .resx file is a royal PITA - I find the default fallback mechanism "good enough" (falls back to the "main" .resx file); I'd find it rather annoying to set my UI to it-CH and have half the UI actually rendered in fr-CH with a bit of de-CH sprinkled in. \$\endgroup\$ Nov 16, 2016 at 15:10
  • \$\begingroup\$ Yes, it tends to become a translation tohuvabohu by design. But that's a part of the requested solution - translation isn't a part of our development, so it's better to have a language near to the requested language than a completely different to reduce the PITA from user perspective. \$\endgroup\$
    – Kelon
    Nov 16, 2016 at 15:18
  • 1
    \$\begingroup\$ You're solving the wrong problem. As @Mat'sMug alluded you could fix this by simplifiying the job for your translators to provide full and accurate translations. FWIW for a project spawned from this community we had a similar problem and I wrote a small tool to fill up unused keys and mark them with a special background color .. \$\endgroup\$
    – Vogel612
    Nov 16, 2016 at 15:39
  • 2
    \$\begingroup\$ I've already suggested to use resxresourcemanager.codeplex.com for program resources / translations. But both of this tools assist you only at the developers point of view. The solution is intended give a better translation at runtime to a user - either the translator or a user which wants to translate just parts of the whole application to his own language. It's a feature, not a problem. \$\endgroup\$
    – Kelon
    Nov 16, 2016 at 16:02
  • \$\begingroup\$ @Kelon Your code has (at least) two major bugs that I discovered - see answer. \$\endgroup\$ Dec 27, 2016 at 19:01

1 Answer 1

12
+500
\$\begingroup\$

Bugs

So, some interesting bugs. If I specify two cultures that have the same invariant, but are different versions it creates really unpleasant circumstances. (Infinite loops, anyone?)

var cultureInfo2 = FallbackCultureInfo.Build("en-GB", "en-US", "fr-CH");

That creates an infinite loop when rooting through the parent. So does:

var cultureInfo2 = FallbackCultureInfo.Build("en-GB", "fr-CH", "fr-FR", "de-CH");

Why does this matter? I could see a very real use case being:

cultureInfo = FallbackCultureInfo.Build("en-AU", "en-GB", "en-US");

(Use the Australian English culture, if you can't find it there use Great Britain English culture, if you can't find it there use the United States English culture.) Though, using the neutral (en) as the second in line may very well solve that problem with most strings, but it's still a possibility that this chain could be used and is now broken.

Of course, it's not consistent because of your static member there.

var cultureInfo = new System.Globalization.CultureInfo("en-GB");
cultureInfo = FallbackCultureInfo.Build("it-CH", "fr-CH", "de-CH");
cultureInfo = FallbackCultureInfo.Build("en-GB", "fr-FR", "de-CH");

When looking through all the parents of that second culture set, I don't get the correct tree.

en-GB
en
fr-CH
fr
de-CH
de

But I specified fr-FR for the second fallback!?!?!?!

Of course, we can get even more interesting results with a few other options:

cultureInfo = FallbackCultureInfo.Build("it-CH", "fr-CH", "de-CH");
cultureInfo = FallbackCultureInfo.Build("fr-CH", "it-CH", "de-CH");
fr-CH
fr
de-CH
de

Wait, what? Where did it-CH go?

cultureInfo = FallbackCultureInfo.Build("it-CH", "fr-CH", "de-CH");
cultureInfo = FallbackCultureInfo.Build("fr-CH");
fr-CH
fr
de-CH
de

Ah, I guess I really did need de-CH after all.

While both of these bugs are pretty major, for your situation they're really not something you would look for. You are specifically switching between languages that have different parents, and you're only creating one FallbackCultureInfo. (Which is probably the most likely scenario.)


Review

In C# we prefer the string alias instead of the String type.

Other than that, I have no real issues with the structure of your code, but I do have an issue with how you solved the problem.


Alternate Implementation

From what understand of the documentation, you should be able to get away with making this a lot simpler:

The cultures have a hierarchy in which the parent of a specific culture is a neutral culture, the parent of a neutral culture is the InvariantCulture, and the parent of the InvariantCulture is the invariant culture itself. The parent culture encompasses only the set of information that is common among its children.

If the resources for the specific culture are not available in the system, the resources for the neutral culture are used. If the resources for the neutral culture are not available, the resources embedded in the main assembly are used. For more information on the resource fallback process, see Packaging and Deploying Resources in Desktop Apps.

Basically, you should be able to just work with the Parent property and build from there.

public class NewFallbackCultureInfo : CultureInfo
{
    public NewFallbackCultureInfo FallbackCulture { get; }

    public NewFallbackCultureInfo(string name, params string[] names)
        : base(name)
    {
        if (names.Length > 0)
        {
            FallbackCulture = new NewFallbackCultureInfo(this, names);
        }
    }

    private NewFallbackCultureInfo(CultureInfo sourceCulture, params string[] names)
        : base(sourceCulture.Parent.Name)
    {
        var newNames = new string[names.Length - 1];

        for (int i = 1; i < names.Length; i++)
        {
            newNames[i - 1] = names[i];
        }

        FallbackCulture = new NewFallbackCultureInfo(names[0], newNames);
    }

    public override CultureInfo Parent => FallbackCulture ?? base.Parent;
}

Note that we also built this without the .Build pattern, and relied instead on constructors. It's more natural this way, and preserves the original CultureInfo usage.

The only downside I see is that the original parent chain may not be preserved, if it goes more than one level.

We can fix that by modifying our private constructor:

private NewFallbackCultureInfo(CultureInfo sourceCulture, params string[] names)
    : base(sourceCulture.Parent.Name)
{
    if (string.IsNullOrEmpty(base.Parent.Name))
    {
        var newNames = new string[names.Length - 1];

        for (int i = 1; i < names.Length; i++)
        {
            newNames[i - 1] = names[i];
        }

        FallbackCulture = new NewFallbackCultureInfo(names[0], newNames);
    }
    else
    {
        FallbackCulture = new NewFallbackCultureInfo(this, names);
    }
}

When tracing the Parent chain, I found that the chain produced by my version is identical to the chain produced by your version, except it doesn't break when tested against the criteria that broke your version.


I apologize if this felt brutal, but I was actually having a bit of fun with it after I realized what was happening.

\$\endgroup\$
4
  • \$\begingroup\$ Thanks for your review! That's what I requested, it's just code, and it doesn't feel brutal :) Okay - string/String - I agree, but thats just a team internal agreement for syntax highlighting (Which I don't like as well) The static/Build()-pattern is necessary to achieve instance uniqueness as described in its xmldoc. At the beginning, I used the same way you did. But Microsoft distributes with a missing operator== in CultureInfo, while the ResourceManager uses it, which leads to a broken resource stack. I'll review the other parts of your review as soon as my current work has been done . \$\endgroup\$
    – Kelon
    Dec 29, 2016 at 13:47
  • \$\begingroup\$ @Kelon Care to elaborate on what you mean by 'instance uniqueness', and 'broken resource stack'? \$\endgroup\$ Dec 29, 2016 at 16:00
  • \$\begingroup\$ Due to the missing operator== overload, the ResourceManager does a ReferenceEquals()-Call instead of an Equals()-Call to find a loaded ResourceSet whenever it searches for a fallback resource value. To reproduce this problem, just change the CurrentUICulture a second time after requesting the resource. Mhm, But Okay, you're creating all fallbacks inside of the constructor. Perhaps thats already a good way to fill this gap - thanks again. \$\endgroup\$
    – Kelon
    Dec 30, 2016 at 8:51
  • \$\begingroup\$ This answer has been selected as the winner of Best of Code Review 2016 — Exterminator. \$\endgroup\$ Jan 18, 2017 at 19:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.