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 [^].
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:
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:
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:
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 string
s 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:
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.
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.
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:
result = String.Format("Name: {0} Age: {1} # of Friends: {2:N2}", _
person.Name, person.Age, person.Friends.Count)
The new way:
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).
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.
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:
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.
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!
result = CustomFormat("{Name}{0.Name}{1.Name}", person0, person1)
An empty placeholder, "{}
" or "{:format}
", refers to the current item in scope. This can result in much shorter conditional statements:
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:
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:
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:
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:
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:
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 string
s, 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:
- Reflection isn't strongly-typed. This means that you could misspell a property name, and not know about it until run-time.
- 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:
- Throw an exception. Example:
CustomFormat("{Invalid}")
(throws an exception). - Output the error. Example:
CustomFormat("{Invalid}") = "{Error: Unknown Selector 'Invalid'}"
. - 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!