A Smarter (or Pure Evil) ToString with Extension Methods
Three years ago I postulated about a ToString implementation for C# that seemed useful to me and a few days later I threw it out on the blog. We used in at my old company for a number of things.
Just now I realized that it'd be even more useful (or more evil) with Extension Methods, so I opened up the old project, threw in some MbUnit tests and changed my implementation to use extension methods.
So, like bad take-out food, here it is again, updated as ToString() overloads:
[Test]
public void MakeSimplePersonFormattedStringWithDoubleFormatted()
{
Person p = new Person();
string foo = p.ToString("{Money:C} {LastName}, {ScottName} {BirthDate}");
Assert.AreEqual("$3.43 Hanselman, {ScottName} 1/22/1974 12:00:00 AM", foo);
}
[Test]
public void MakeSimplePersonFormattedStringWithDoubleFormattedInHongKong()
{
Person p = new Person();
string foo = p.ToString("{Money:C} {LastName}, {ScottName} {BirthDate}",new System.Globalization.CultureInfo("zh-hk"));
Assert.AreEqual("HK$3.43 Hanselman, {ScottName} 1/22/1974 12:00:00 AM", foo);
}
It's moderately well covered for all of the hour it took to write it, and I find it useful.
Here's all it is.
public static class FormattableObject
{
public static string ToString(this object anObject, string aFormat)
{
return FormattableObject.ToString(anObject, aFormat, null);
}
public static string ToString(this object anObject, string aFormat, IFormatProvider formatProvider)
{
StringBuilder sb = new StringBuilder();
Type type = anObject.GetType();
Regex reg = new Regex(@"({)([^}]+)(})",RegexOptions.IgnoreCase);
MatchCollection mc = reg.Matches(aFormat);
int startIndex = 0;
foreach(Match m in mc)
{
Group g = m.Groups[2]; //it's second in the match between { and }
int length = g.Index - startIndex -1;
sb.Append(aFormat.Substring(startIndex,length));
string toGet = String.Empty;
string toFormat = String.Empty;
int formatIndex = g.Value.IndexOf(":"); //formatting would be to the right of a :
if (formatIndex == -1) //no formatting, no worries
{
toGet = g.Value;
}
else //pickup the formatting
{
toGet = g.Value.Substring(0,formatIndex);
toFormat = g.Value.Substring(formatIndex+1);
}
//first try properties
PropertyInfo retrievedProperty = type.GetProperty(toGet);
Type retrievedType = null;
object retrievedObject = null;
if(retrievedProperty != null)
{
retrievedType = retrievedProperty.PropertyType;
retrievedObject = retrievedProperty.GetValue(anObject,null);
}
else //try fields
{
FieldInfo retrievedField = type.GetField(toGet);
if (retrievedField != null)
{
retrievedType = retrievedField.FieldType;
retrievedObject = retrievedField.GetValue(anObject);
}
}
if (retrievedType != null ) //Cool, we found something
{
string result = String.Empty;
if(toFormat == String.Empty) //no format info
{
result = retrievedType.InvokeMember("ToString",
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.InvokeMethod | BindingFlags.IgnoreCase
,null,retrievedObject,null) as string;
}
else //format info
{
result = retrievedType.InvokeMember("ToString",
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.InvokeMethod | BindingFlags.IgnoreCase
,null,retrievedObject,new object[]{toFormat,formatProvider}) as string;
}
sb.Append(result);
}
else //didn't find a property with that name, so be gracious and put it back
{
sb.Append("{");
sb.Append(g.Value);
sb.Append("}");
}
startIndex = g.Index + g.Length +1 ;
}
if (startIndex < aFormat.Length) //include the rest (end) of the string
{
sb.Append(aFormat.Substring(startIndex));
}
return sb.ToString();
}
}
Enjoy.
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
Scott, I have great respect for you, but this feels like a very dangerous anti-pattern to me!! ;-)
I'd probably call TypeDescriptor.GetProperties though instead of doing reflection manually. That's more in-line with how Data Binding resolves properties and gives the runtime a chance to cache the descriptors. The descriptors still use MemberInfo.GetValue/SetValue, but the runtime may speed this up someday by creating delegates and invoking them directly.
@Ruurd: true, but plenty of other things won't be found when you refactor either. References to objects from XAML, XAML {Binding } elements, Windows Forms data bindings, the list goes on. That's why we test :)
It'd be better if you could cache them, but this won't clear the issue related with the compiler.
Paul - I'll take a look at TypeDescriptor. There's another interesting pattern where you can code-gen via CodeDom a property getter, then stick that newly generated code into a hashtable and call it MUCH faster than Reflection. I might do a post on that just because it's obscure. Of course, I'll update this when C# has a "dynamic" keyword. ;)
I'm real hot for WPF, but Xaml is evil in this respect as well (think databinding.. ouch).
Anyway, I always create const strings that define the names inside of my class, so that I can use them all over the code base and only having to change them in one place (right next to the definition of the property itself!). I'll use those in String.Formats and whatnot.
I'll have to think about LINQifying. Don't see an obvious route to take!
http://www.acceptedeclectic.com/2008/04/when-should-i-employ-extension-methods.html
^^In the above linked post, I cite your work and I mention how much I love this formatting trick, but I'm struggling to understand what is the benefit of the extension method part.
As far as people who are concerned about refactoring and compile-time checking...
Compiling != Testing... Testing == Testing !!!
As Scott H says, there are plenty of other examples of tunnelled languages that can contain syntactic errors that won't be picked up by the compiler (SQL as well, in addition to the examples Scott H provides).
If you wanted to you could implement some kind of static checking process that would check this for errors.
Any ideas on how I can make the text box the same length as the others?
result = retrievedType.InvokeMember("ToString",
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.InvokeMethod | BindingFlags.IgnoreCase
, null, retrievedObject, new object[] { toFormat, formatProvider }) as string;
why not just:
result = retrievedObject.ToString(toFormat, formatProvider);
??
Then, you regex (@"({)([^}]+)(})") is not fully correct. You use recursion for field/properties wich formatting.
So if write:
myObject.ToString("{MyProp:format}")
here 'format' is format-string which will be transmitted into next call ot ToString() - for the MyProp's value.
If so it's logical to expect that format-string can be really format-string wich own formatting:
myObject.ToString("{MyProp:{MyPropOfValueOfMyProp}}")
but it won't work due to your regex.
For the *second* call, if I change that to call directly it actually calls the extension method again and recurses until it dies. That makes me realize that perhaps I'd need another parameter, or even better, to name ToString something else like ToStringWithFormatting.
I used the DataBinder.Eval method to do most of the heavy lifting.
FormatWith 2.0 - String formatting with named variables
Comments are closed.