Code Focused

Adding New Formats to Old Data

The custom formatters in the .NET Framework provide a more seamless way to mix traditional and custom formatting situations.

The core .NET data types include a nice variety of pre-defined and custom formatting codes that you can use when merging string templates and data elements together:

message = String.Format(
  "As of {0:M/d/yyyy}, your balance is {1:C}.",
  billDate, amountDue);

But what if that nice variety of formatting codes doesn't meet your needs? No problem. Just make up your own. For example, here's some code with a completely made up "SO" formatting instruction that spells out numeric data values in words:

message = String.Format(new NumberWordsProvider(),
  "The number {0}, when spelled out, is:\r\n{0:SO}",
  someNumber);
// ----- Sample result:
//         The number -234.45, when spelled out, is:
//         Minus Two Three Four Dot Four Five

Of course, the SO format code doesn't ship with the .NET Framework. Instead, you need to provide logic that intercepts the SO request, and performs the desired conversion. In the sample, that's done through the NumberWordsProvider instance, a "format provider" class that will be developed throughout this article.

All format provider classes implement the IFormatProvider interface. It's a general-purpose interface that enables a few different types of formatting systems, including the one we care about, the "custom formatter." Custom formatters must also implement the ICustomFormatter interface. The NumberWordsProvider class implements both required interfaces:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

public class NumberWordsProvider : IFormatProvider, ICustomFormatter
{
}

Let's handle the IFormatProvider part first. It exposes a single member, the GetFormat method. When called by .NET, its job is to confirm whether the class supports a specific formatting interface, such as ICustomFormatter. If so, it returns the class instance itself. A null result indicates that the specific formatter isn't supported:

object IFormatProvider.GetFormat(Type formatType)
{
  // ----- This class only handles ICustomFormatter.
  if (formatType == typeof(ICustomFormatter))
    return this;
  else
    re
turn null; }

It's time to move on to the ICustomFormatter interface and its Format method, which will house all remaining code in this article:

string ICustomFormatter.Format(string format, object arg,
  IFormatProvider formatProvider)
{
}

This function is called once for each curly brace placeholder in the format template string. In each call, the format parameter contains the post-colon formatting code, and arg is the piece of data to be formatted. Consider again the "when spelled out" example:

message = String.Format(new NumberWordsProvider(),
  "The number {0}, when spelled out, is:\r\n{0:SO}",
  someNumber);

In this case, the ICustomFormatter.Format method will be called twice: once with someNumber as the arg value, and with a null formatting code; and a second time with someNumber as the arg value, but with SO as the format text. (The third parameter, formatProvider, is typically just the provider instance. In this case, it's the NumberWordsProvider instance.) The code in the Format method must examine the format string, see if it’s compatible with the supplied arg value, and return a string that reimagines the data according to the instructions in the format code.

This article's example will emit English words for the digits, decimal point, and minus sign included in the supplied integer or floating point value. A fancier implementation would support other languages, but my parents taught me only English. Listing 1 shows a conversion table between each character and the target English term, plus a few other helper variables.

Listing 1: The Numerical-to-English Conversion Table
// ----- Format an ordinary number as English words.
string baseNumber;
string coreFormat;
StringBuilder result;
bool somethingElse;
Dictionary<char, string> pieces = new Dictionary<char, string>()
{
  { '-', "Minus " }, { '.', "Dot "   },
  { '1', "One "   }, { '2', "Two "   },
  { '3', "Three " }, { '4', "Four "  },
  { '5', "Five "  }, { '6', "Six "   },
  { '7', "Seven " }, { '8', "Eight " },
  { '9', "Nine "  }, { '0', "Zero  " }
};

This custom formatter will support three formatting codes: first, the aforementioned SO code, which spells out a number in words; second, the related SU code, which produces the same output, but in uppercase; and third, the SL code, a lowercase variant. If the logic encounters some other code, it will mark it as "something else," and process it differently. At the same time, the code converts the incoming data into a string that can be examined character-by-character, assuming that the data is, in fact, a number (see Listing 2).

Listing 2: Converting from Number to Text
// ----- Convert the number to text.
coreFormat = format?.ToUpper() + "";
somethingElse = false;
baseNumber = "";
if ((coreFormat != "SU") & (coreFormat != "SL") & (coreFormat != "SO"))
  somethingElse = true;
else if ((arg is byte) | (arg is ushort) | (arg is uint) | (arg is ulong))
  baseNumber = Convert.ToUInt64(arg).ToString();
else if ((arg is sbyte) | (arg is short) | (arg is int) | (arg is long))
  baseNumber = Convert.ToInt64(arg).ToString();
else if ((arg is float) | (arg is double))
  baseNumber = Convert.ToDouble(arg).ToString();
else if (arg is decimal)
  baseNumber = Convert.ToDecimal(arg).ToString();
else
  somethingElse = true;

With the appropriate formatting code and a prepared numeric string in hand, it's time to format some data. First, the code deals with those "something else" situations, where the formatting code is different and mysterious, or where the data isn't a number. The routine could error out at this point, but a better solution is to defer to the normal behavior for the specific data/format pair, as shown in Listing 3.

Listing 3: Formatting for Those "Something Else" Situations
// ----- Deal with unknown data or formats.
if (somethingElse == true)
{
  // ----- Not one of our formats, but perhaps still valid.
  if (arg is IFormattable)
  {
    // ----- The object includes its own internal formatter.
    try
    {
      return ((IFormattable)arg).ToString(
        format, CultureInfo.CurrentCulture);
    }
    catch (FormatException ex)
    {
      if (coreFormat.Length > 0)
        throw new FormatException(
          $"The format '{format}' is invalid.", ex);
      else
        throw new FormatException(
          "The supplied argument is not compatible.", ex);
    }
  }
  else if (arg != null)
  {
    // ----- The formatting code is missing or
    //       seems meaningless. Emit the object's
    //       default text representation.
    return arg.ToString();
  }
  else
  {
    // ----- No data. At the very least, don't crash.
    return String.Empty;
  }
}

Some data types support the IFormattable interface. While the ICustomFormatter interface allows an independent class to format other data types, the IFormattable interface exists for those times when a class knows how to format itself, with optional formatting codes. The Decimal data type, for example, knows how to emit a currency string when told to format itself with the "C" format code:

message = String.Format("You owe {0:C}.", amountDue);

The NumberWordsProvider class passes such format requests on to the data value's own custom formatter. If the data element doesn't support this level of self-formatting, the code uses the data's default ToString implementation, or returns an empty string for missing data.

Now that all of the non-standard cases have been dealt with, it's time to perform the promised formatting. The first step is to convert each character in the numeric string to its English-term counterpart, using the lookup dictionary:

// ----- Assemble the parts.
result = new StringBuilder();
for (int counter = 0; counter < baseNumber.Length; counter++)
  if (pieces.ContainsKey(baseNumber[counter]))
    result.Append(pieces[baseNumber[counter]]);

The only task remaining is to convert the output to uppercase or lowercase, if requested:

// ----- Final adjustment for case.
if (coreFormat == "SU")
  return result.ToString().Trim().ToUpper();
else if (coreFormat == "SL")
  return result.ToString().Trim().ToLower();
else // if (coreFormat == "SO")
  return result.ToString().Trim();

In addition to the String.Format command, you can employ custom formatters in calls to StringBuilder.AppendFormat, and other similar formatting situations. You could always write an independent method to assemble the pre-formatted parts of your content. But custom formatters provide a more seamless way to mix traditional and custom formatting situations.

About the Author

Tim Patrick has spent more than thirty years as a software architect and developer. His two most recent books on .NET development -- Start-to-Finish Visual C# 2015, and Start-to-Finish Visual Basic 2015 -- are available from http://owanipress.com. He blogs regularly at http://wellreadman.com.

comments powered by Disqus
Upcoming Events

.NET Insight

Sign up for our newsletter.

I agree to this site's Privacy Policy.