AdvancedFormatProvider: Making string.format do more

When I have an integer that I want to format within the String.Format() and ToString(format) methods, I’m always forgetting the format symbol to use with it. That’s probably because its not very intuitive.

Use {0:N0} if you want it with group (thousands) separators.

text = String.Format("{0:N0}", 1000); // returns "1,000"
 
int value1 = 1000;
text = value1.ToString("N0");

Use {0:D} or {0:G} if you want it without group separators.

text = String.Format("{0:D}", 1000); // returns "1000"
 
int value2 = 1000;
text2 = value2.ToString("D");

The {0:D} is especially confusing because Microsoft gives the token the name “Decimal”.

I thought it reasonable to have a new format symbol for String.Format, "I" for integer, and the ability to tell it whether it shows the group separators. Along the same lines, why not expand the format symbols for currency ({0:C}) and percent ({0:P}) to let you omit the currency or percent symbol, omit the group separator, and even to drop the decimal part when the value is equal to the whole number?

My solution is an open source project called AdvancedFormatProvider, a group of classes that provide the new format symbols, continue to support the rest of the native symbols and makes it easy to plug in additional format symbols. Please visit https://github.com/plblum/AdvancedFormatProvider to learn about it in detail and explore how its implemented. The rest of this post will explore some of the concepts it takes to expand String.Format() and ToString(format).

AdvancedFormatProvider benefits:

  • Supports {0:I} token for integers. It offers the {0:I-,} option to omit the group separator.
  • Supports {0:C} token with several options. {0:C-$} omits the currency symbol. {0:C-,} omits group separators, and {0:C-0} hides the decimal part when the value would show “.00”. For example, 1000.0 becomes “$1000” while 1000.12 becomes “$1000.12”.
  • Supports {0:P} token with several options. {0:P-%} omits the percent symbol. {0:P-,} omits group separators, and {0:P-0} hides the decimal part when the value would show “.00”. For example, 1 becomes “100 %” while 1.1223 becomes “112.23 %”.
  • Provides a plug in framework that lets you create new formatters to handle specific format symbols. You register them globally so you can just pass the AdvancedFormatProvider object into String.Format and ToString(format) without having to figure out which plug ins to add.
text = String.Format(AdvancedFormatProvider.Current, "{0:I}", 1000); // returns "1,000"
text2 = String.Format(AdvancedFormatProvider.Current, "{0:I-,}", 1000);    // returns "1000"
text3 = String.Format(AdvancedFormatProvider.Current, "{0:C-$-,}", 1000.0);   // returns "1000.00"

The IFormatProvider parameter

Microsoft has made String.Format() and ToString(format) format expandable. They each take an additional parameter that takes an object that implements System.IFormatProvider. This interface has a single member, the GetFormat() method, which returns an object that knows how to convert the format symbol and value into the desired string.

There are already a number of web-based resources to teach you about IFormatProvider and the companion interface ICustomFormatter. I’ll defer to them if you want to dig more into the topic. The only thing I want to point out is what I think are implementation considerations.

Why GetFormat() always tests for ICustomFormatter

When you see examples of implementing IFormatProviders, the GetFormat() method always tests the parameter against the ICustomFormatter type. Why is that?

public object GetFormat(Type formatType)
{
   if (formatType == typeof(ICustomFormatter))
      return this;
   else 
      return null;
}

The value of formatType is already predetermined by the .net framework. String.Format() uses the StringBuilder.AppendFormat() method to parse the string, extracting the tokens and calling GetFormat() with the ICustomFormatter type. (The .net framework also calls GetFormat() with the types of System.Globalization.NumberFormatInfo and System.Globalization.DateTimeFormatInfo but these are exclusive to how the System.Globalization.CultureInfo class handles its implementation of IFormatProvider.)

Your code replaces instead of expands

I would have expected the caller to pass in the format string to GetFormat() to allow your code to determine if it handles the request. My vision would be to return null when the format string is not supported. The caller would iterate through IFormatProviders until it finds one that handles the format string. Unfortunatley that is not the case.

The reason you write GetFormat() as above is because the caller is expecting an object that handles all formatting cases. You are effectively supposed to write enough code in your formatter to handle your new cases and call .net functions (like String.Format() and ToString(format)) to handle the original cases.

Its not hard to support the native functions from within your ICustomFormatter.Format function. Just test the format string to see if it applies to you. If not, call String.Format() with a token using the format passed in.

public string Format(string format, object arg, IFormatProvider formatProvider)
{
   if (format.StartsWith("I"))
   {
      // handle "I" formatter
   }
   else
      return String.Format(formatProvider, "{0:" + format + "}", arg);  
}

Formatters are only used by explicit request

Each time you write a custom formatter (implementer of ICustomFormatter), it is not used unless you explicitly passed an IFormatProvider object that supports your formatter into String.Format() or ToString(). This has several disadvantages:

  • Suppose you have several ICustomFormatters. In order to have all available to String.Format() and ToString(format), you have to merge their code and create an IFormatProvider to return an instance of your new class.
  • You have to remember to utilize the IFormatProvider parameter. Its easy to overlook, especially when you have existing code that calls String.Format() without using it.
  • Some APIs may call String.Format() themselves. If those APIs do not offer an IFormatProvider parameter, your ICustomFormatter will not be available to them.

The AdvancedFormatProvider solves the first two of these problems by providing a plug-in architecture.

No Comments