Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / VB10

Custom Formatting in .NET - Enhancing String.Format to all new levels!

4.91/5 (63 votes)
1 Dec 2010CDDL9 min read 139.3K   1.2K  
A string template method that allows you to fill a string with data. Easy to use, fast, extensible, and extremely powerful! Uses Reflection, Conditional Formatting, iterating through Arrays, and more!

C# Version

Development of this project has been continued in C# at http://github.com/scottrippey/SmartFormat [^]. You can download the latest version there. You can also read the Wiki at http://github.com/scottrippey/SmartFormat/wiki [^].

Screen.jpg

Introduction

Have you seen this kind of text before? "There are 1 items remaining..."

Seeing a grammatically-incorrect sentence like this can be irritating, but we all know why we see them. Programmers are lazy! Even a beginner programmer knows how to solve such a problem, but it still exists because building strings in code is tedious and messy! For example, who would want to write this:

VB.NET
count = Random(1, 2)
output = "There " & IIf(count=1,"is ","are ") & _
         count.ToString() & " item" & _
         IIf(count=1,"","s") & "remaining..."

Hope you will never need to change the text or translate the program into another language!

I have been working for months on making "composite formatting" easier and more powerful, and what I have come up with is a CustomFormat function that is easy to use, fast, efficient, extremely enhanced, and powerfully extensible!

The above example could be rewritten as:

VB.NET
count = Random(1, 2)
output = CustomFormat("There {0:is|are} {0} item{0:|s} remaining...", count)

This example uses the Conditional Formatting functionality; keep reading to see what other powerful features are included!

Who Is This For?

I literally use this code in every program I write, and I think you will too! It makes turning data into text a breeze. It is incredibly useful for:

  • Displaying "natural language" in the User Interface.
  • Displaying data for debugging purposes (for example, overriding ToString).
  • Localization! Being able to program some grammatical logic into a string is crucial to properly translating an application.

The Basic CustomFormat Syntax

The CustomFormat method starts off by replacing the String.Format function that is built-in to .NET:

VB.NET
result = CustomFormat("{0} is {1} years old and has {2:N2} friends.", _
         person.Name, person.Age, person.Friends.Count)

Just like String.Format, CustomFormat takes a string (that contains placeholders marked by { curly braces }) followed by a list of arguments. If you want detailed information about this method, see: String.Format Composite Formatting at Microsoft’s website.

However, the CustomFormat function picks up where the String.Format function left off! It includes a lot of enhanced features. Reflection allows property names to be used as meaningful placeholders. Conditional formatting allows you to choose different text depending on a value (solving the "plural-problem" described earlier). Array formatting allows you to determine how to output multiple items from a collection. Plus, your own additional logic and functionality can be easily added in your own projects!

Basic CustomFormat Features

Here is a brief overview of some of the features of the CustomFormat function, including simple examples. Full descriptions are found later in this article. Any of these features can be combined, making it incredibly easy to build otherwise complex strings out of your data.

Basic Reflection

Instead of using indexed placeholders (example: "There are {0} items"), you can use the actual names of properties (example: "There are {Items.Count} items"). Reflection works on Fields, Properties, and even parameterless Functions, and can be chained together using dot-notation:

VB.NET
result = CustomFormat("{Name} is {Age} years old " & _ 
         "and has {Friends.Count:N2} friends.", person)

Basic Conditional Formatting

Based on the value of an argument, you can determine what text to output.

VB.NET
result = CustomFormat("There {0:is|are} {0} {0:item|items} remaining…", items.Count)

The way it works? The first placeholder is "{0:is|are}", so if items.Count = 1, the first option "is" will be chosen; otherwise, the second option "are" will be chosen. More complicated functionality is possible; see the detailed Conditional Formatting description later in this article.

Basic Array Formatting

Outputting items from an array has never been easier. When an array (or anything that implements ICollection) is encountered, each item in the array will be outputted.

VB.NET
result = CustomFormat("All dates: {0:MM/dd/yyyy| and }.", dates)

Result: "All dates: 12/31/1999 and 10/10/2010 and 1/1/3000."

Assuming dates is an array, the placeholder will repeat the output for each item in the array, formatting using "MM/dd/yyyy" and separating each item with " and ".

Extensibility

You can extend the CustomFormat function by attaching to the ExtendCustomFormat event. This allows you to customize the output any way you like! For example, the TimeSpan data type always outputs an ugly string like "00:01:00:01.0050". By handling the ExtendCustomFormat event, you could output "1 hour 1 minute 5 milliseconds". This example is included in the demo.

Advanced Reflection

The old way:

VB.NET
result = String.Format("Name: {0} Age: {1}  # of Friends: {2:N2}", _
         person.Name, person.Age, person.Friends.Count)

The new way:

VB.NET
result = CustomFormat("Name: {Name}   Age: {Age}   _
	# of friends: {Friends.Count:N2}", person)

The properties can be chained together by using dot-notation. Each item in the dot-notation is case-sensitive, and may be any Property, Field, or even parameterless Function.

Argument Indexes

Just like String.Format, you can pass in several arguments into the CustomFormat method. Each item can be referenced using its index. However, "0." is the default and can always be omitted (see Scope below).

VB.NET
result = CustomFormat("Person0: {0.Name}, Person1: {1.Name}", person0, person1)

Nesting

There is an alternative to using the dot-notation: nesting. Both nesting and dot-notation can be used within the same format string.

VB.NET
dotNotation = CustomFormat(" {Address.City}, {Address.State} {Address.Zip} ", person)
nested = CustomFormat("{Address: {City}, {State} {Zip} }", person)

These two lines produce identical output; however, in this example, the nested format is a much cleaner solution.

There are certain scenarios where using nested placeholders makes the format string easier to understand (such as above), and certain scenarios where using nested placeholders is necessary:

VB.NET
Dim sizes() as Size = {New Size(1,1), New Size(4,3), New Size(16,9)} 
Dim result$ = CustomFormat("{0:({Width} x {Height})| and }", sizes)

The Array Formatter will iterate through each Size in sizes, and output the nested format "({Width} x {Height})", separated by " and ".

Result: "(1 x 1) and (4 x 3) and (16 x 9)".

Scope

When using nested placeholders, it is necessary to understand the scope that Reflection will use. A nested placeholder always starts off with the scope of the item that contains it.

VB.NET
result = CustomFormat("{0.Address: {City}, {State} {Zip} }", person)

In this example, the scope for "{City}, {State} {Zip} " is "0.Address".

The initial scope for the CustomFormat function is automatically the first argument. Therefore, the "0." can always be omitted!

VB.NET
result = CustomFormat("{Name}{0.Name}{1.Name}", person0, person1)
'(due to scope, the "0." can be omitted)

An empty placeholder, "{}" or "{:format}", refers to the current item in scope. This can result in much shorter conditional statements:

VB.NET
result = CustomFormat("{Friends.Count: There {:is|are} {} friend{:|s}.}", person)

And finally, the only way to access an item out-of-scope is to use the argument index:

VB
INVALID = CustomFormat("{Address: {City}, {State}, {0.Age} {Name} } ", person)

The above code will work for "{0.Age}" because the "0" always refers to the first argument (person). However, "{Name}" will fail because Address does not contain a Name property!

Advanced Conditional Formatting

Conditional Formatting is my favorite functionality, and the main reason I started this project.

Conditional Formatting will kick in on any placeholder that contains a "|" (pipe character) after the ":".

Syntax

The behavior of Conditional Formatting varies depending on the data type of the placeholder.

Data type:Syntax:Example:
Number (Integer, Double, etc...)"{0:one|default}""<code>{0} {0:item|items}"
"{0:zero|one|default}""{0:no items|one item|many items}"
"{0:negative|zero|one|default}""{0:negative items|no items|one item|many items}"
Also see Complex Conditions (below) 
Boolean"{0:true|false}""Enabled? {0:Yes|No}"
String"{0:default|null or empty}""Text: {0:{0}|No text to display}"
Date"{0:before|after}" (as compared to Date.Now)"My birthday {0:was on|will be on} {0:MMMM d}"
TimeSpan"{0:negative|positive}""My birthday {0:was {0} ago|will be in {0} from now}"
 "{0:negative|zero|positive}""My birthday {0:was {0} ago|is today!|will be in {0} from now}"
Object (everything else)"{0:default|nothing}""Property: {0:{0}|(Nothing)}"

Complex Conditions

If the source is a Number, then Conditional Formatting also has a simple syntax for supporting complex conditions. Example:

VB.NET
CustomFormat("{Age:>=55?Senior Citizen|>=30?Adult|>" & _ 
             "=18?Young Adult|>12?Teenager|>2?Child|Baby}", person)

Details

Each parameter is separated by "|". The comparison is followed by a "?" and then the text. The last (default) entry does not contain a comparison nor a "?". Valid comparisons: >= > = < <= !=.

Comparisons can be combined using "&" for AND or "/" for OR. Example:

VB.NET
CustomFormat("{Age:=5?Kindergarten|=6/=7/=8/=9/=10?Elementary|>" + 
             "10&<=12?Middle School|>12&<=18?High School|None}", person)

Advanced Array Formatting

Array formatting automatically kicks in for any item that implements ICollection. It uses the same format for each item, and also inserts spacers between items.

Syntax

"{Items:format|spacer}" or "{Items:format|spacer|last spacer}".

format can be any format argument, such as "MM/dd/yyyy" for dates, or can be a nested statement, such as "{Width} x {Height}" for sizes.

Each item, except for the last item, will be followed with the spacer. If a "last spacer" is provided, then it will be used in place of the last "spacer".

Examples will help explain this:

VB.NET
Dim result$ = CustomFormat("{0:MM/dd/yyyy|, }.", dates)
Dim result2$ = CustomFormat("{0:MM/dd/yyyy|, |, and}.", dates)

Result: "1/1/1111, 2/2/2222, 3/3/3333."

Result2: "1/1/1111, 2/2/2222, and 3/3/3333."

If you use nested parameters, then an extra "{Index}" placeholder is available to output the item’s index. The index starts counting at 1. Example:

VB.NET
Dim items() As String = {"A", "B", "C"}
Dim result$ = CustomFormat("{0:{} = {Index}|, }", items)

Result: "A = 1, B = 2, C = 3".

Extensibility (a.k.a. Plug-ins)

The CustomFormat function resides in a class, _CustomFormat, that has a Shared Event ExtendCustomFormat (info As CustomFormatInfo).

The event is pretty well self-documented and commented, but is not the topic of this article.

Using an event is one of many ways to make a plug-in. I chose this way because in Visual Basic, it is very easy to write each plug-in as a separate file that attaches itself to the event. Therefore, the _CustomFormat file isn’t dependent on any other files, and I can selectively add any plug-in to any project without making changes to any files.

Performance Notes

A great deal of my time in this project has been spent on improving the performance, trying to get it to perform as well as the original String.Format. In some of my tests, the function returns in under 5 microseconds! The more complicated the format, the longer it will take, but the entire process is very efficient.

I originally used Regular Expressions to do all my parsing. However, because of the need to have nested placeholders, Regular Expressions were slow and complicated. I recently replaced Regex with my own parsing code and noticed a huge improvement! It also eliminated the chance of crashing.

To build strings, each plugin originally used its own StringBuilder. Now, all output has been consolidated into the argument that is passed to each plug-in, so that the plug-in can use the Write method for its output. This also makes it possible to output directly to a stream.

Criticisms of Using Reflection

Using Reflection has two potential drawbacks:

  1. Reflection isn't strongly-typed. This means that you could misspell a property name, and not know about it until run-time.
  2. Reflection doesn't work well with refactoring. This means that if you change the name of a property, that change won't be reflected in the string template.

Fortunately, you can specify what happens when a string template contains an unrecognized placeholder! There are three options:

  1. Throw an exception. Example: CustomFormat("{Invalid}") (throws an exception).
  2. Output the error. Example: CustomFormat("{Invalid}") = "{Error: Unknown Selector 'Invalid'}".
  3. Ignore. Example: CustomFormat("{Invalid}") = "".

In Debug mode, the default is to throw an exception. This will help you figure out where the error is, and should make it easy to fix. In Release mode, the default is to output the error, so it is possible that the end user will see the error in the output, but an exception will not be thrown.

Please use the demo application to see the CustomFormat function in action!

License

This article, along with any associated source code and files, is licensed under The Common Development and Distribution License (CDDL)