Sometimes it’s fun to do something that any self-respecting developer would cringe if they saw someone else do it. That’s kind of how I feel about abusing the capabilities of C# 4′s dynamic binding capabilities. Even though I stubbornly resisted the idea of adding dynamic binding to C#, I find myself playing around with it every now and then to make really ugly code look nicer.
For example, let’s consider the case where you’re matching a US telephone number (with optional extension) against a regular expression and separating the individual components into separate variables. In the US telecom industry it’s very common to deal with 10 digit numbers without having to worry about international phone formats, but the regular expression is still ugly. Unfortunately there’s nothing I can do about that, but this is about making code pretty, not making regular expressions pretty.
(?<npa>\d{3})-?(?<nxx>\d{3})\-?(?<line>\d{4})( x(?<ext>\d+))?
Let’s look at the implementation of such a task in PowerShell vs. C#.
Here is how the code would look in PowerShell. It’s elegance gets me sexually aroused.
if ('555-123-4567' -match $PhoneRegex) {
$npa = $matches.npa
$nxx = $matches.nxx
$line = $matches.line
$ext = $matches.ext
}
Here is how the equivalent code would look in C#.
var match = Regex.Match("555-123-4567", PhoneRegex);
if (match.Success) {
string npa = match.Groups["npa"].Value;
string nxx = match.Groups["nxx"].Value;
string line = match.Groups["line"].Value;
string ext = match.Groups["ext"].Success ?
match.Groups["ext"].Value :
null;
}
It’s not the end of the world, but it still feels like it could be so much more concise. Well with C# 4 I can create an object that derives from DynamicObject and wraps a System.Text.RegularExpressions.Match object to provide a much more “scripty” feel to the above code.
var match = DynamicRegex.Match("555-123-4567", PhoneRegex);
if (match) {
string npa = match.npa;
string nxx = match.nxx;
string line = match.line;
string ext = match.ext;
}
Note that a static method called DynamicRegex.Match is returning an object of type dynamic. The actual implementation is my wrapper class called DynamicMatch. It overrides the TryGetMember and TryConvert calls that make the above possible. It simply directs property and indexer calls into the Match.Groups collection and has some special logic for conversion to Boolean.
Anyhow, to get into detail about how C# does dynamic binding at runtime would take a series of posts in itself. I just thought this was a pretty interesting use of the dynamic binding that wasn’t yet another dynamic XML wrapper. Yeah I know it defeats the purpose of using a strongly-typed language like C# but oh well I thought it was interesting. Source is below.
public sealed class DynamicMatch : DynamicObject
{
private readonly Regex _Regex;
private readonly Match _Match;
public DynamicMatch( Regex regex, Match match )
{
Contract.Requires( regex != null, "Regex cannot be null." );
Contract.Requires( match != null, "Match cannot be null." );
_Regex = regex;
_Match = match;
}
public override IEnumerable GetDynamicMemberNames( )
{
// i honestly don't know where the hell this is used
return _Regex.GetGroupNames( );
}
public override bool TryConvert( ConvertBinder binder, out object result )
{
// supports casting back to the original Match object
if ( binder.Type == typeof( Match ) ) {
result = _Match;
return true;
}
// supports casting the match to its string representation
// is the complete match result
if ( binder.Type == typeof( String ) ) {
if ( _Match.Success ) {
result = _Match.Value;
return true;
}
else {
result = null;
return true;
}
}
// supports casting the match to a boolean indicating success
if ( binder.Type == typeof( Boolean ) || binder.Type == typeof(Boolean?) ) {
result = _Match.Success;
return true;
}
return base.TryConvert( binder, out result );
}
public override bool TryGetIndex( GetIndexBinder binder, object[] indexes, out object result )
{
// supports 'match[x]' where x is a named capture group
// or 'match[n]' where n is an implicit capture group index
if ( indexes.Length == 1 ) {
Group group = _Match.Groups[Convert.ToString( indexes[0] )];
if ( group != null && group.Success ) {
result = group.Value;
return true;
}
else {
result = null;
return true;
}
}
return base.TryGetIndex( binder, indexes, out result );
}
public override bool TryGetMember( GetMemberBinder binder, out object result )
{
// supports 'match.x' where x is a named capture group
Group group = _Match.Groups[binder.Name];
if ( group != null && group.Success ) {
result = group.Value;
return true;
}
else {
result = null;
return true;
}
}
public override bool TryUnaryOperation( UnaryOperationBinder binder, out object result )
{
// supports 'if (match) {...}'
if ( binder.Operation == ExpressionType.IsTrue ) {
result = _Match.Success;
return true;
}
// supports 'if (!match) {...}'
if ( binder.Operation == ExpressionType.IsFalse || binder.Operation == ExpressionType.Not ) {
result = !_Match.Success;
return true;
}
return base.TryUnaryOperation( binder, out result );
}
public static bool operator ==( DynamicMatch x, bool y )
{
// supports 'if (match == true) {...}'
return x._Match.Success == y;
}
public static bool operator !=( DynamicMatch x, bool y )
{
// supports 'if (match != true) {...}'
return x._Match.Success != y;
}
public static bool operator ==( DynamicMatch x, string y )
{
// supports 'if (match == "foo") {...}'
return x._Match.Value == y;
}
public static bool operator !=( DynamicMatch x, string y )
{
// supports 'if (match != "foo") {...}'
return x._Match.Value != y;
}
}
public static class DynamicRegex
{
public static dynamic Match( string input, string pattern )
{
var regex = new Regex( pattern );
var match = regex.Match( input );
return new DynamicMatch( regex, match );
}
}
[TestClass]
public class DynamicRegexTests
{
[TestMethod]
public void DynamicMatchConvertsToMatch( )
{
var dynamicMatch = DynamicRegex.Match( "Hello World", @"H[A-Za-z]+" );
Match regularMatch = dynamicMatch;
Assert.IsTrue( regularMatch.Success );
Assert.AreEqual( "Hello", regularMatch.Value );
}
[TestMethod]
public void SuccessfulMatchConvertsToTrue( )
{
var match = DynamicRegex.Match( "Hello World", @"H[A-Za-z]+" );
// Convert To Boolean
Assert.IsTrue( (bool)match, "Successful match should convert to true." );
// Compare To Boolean
if ( !match ) { Assert.Fail( "Successful match should convert to true." ); }
if ( match == false ) { Assert.Fail( "Successful match should convert to true." ); }
if ( match != true ) { Assert.Fail( "Successful match should convert to true." ); }
}
[TestMethod]
public void UnsuccessfulMatchConvertsToFalse( )
{
var match = DynamicRegex.Match( "Hello World", @"^H[A-Za-z]+$" );
// Convert To Boolean
Assert.IsFalse( (bool)match, "Unsuccessful match should convert to false." );
// Compare To Boolean
if ( match ) { Assert.Fail( "Unsuccessful match should convert to false." ); }
if ( match == true ) { Assert.Fail( "Unsuccessful match should convert to false." ); }
if ( match != false ) { Assert.Fail( "Unsuccessful match should convert to false." ); }
}
[TestMethod]
public void SuccessfulMatchConvertsToString( )
{
var match = DynamicRegex.Match( "Hello World", @"H[A-Za-z]+" );
// Convert To String
Assert.AreEqual( "Hello", (string)match, "Successful match should convert to Match.Value." );
// Compare To String
if ( match != "Hello" ) { Assert.Fail( "Successful match should convert to Match.Value." ); }
}
[TestMethod]
public void UnsuccessfulMatchConvertsToNull( )
{
var match = DynamicRegex.Match( "Hello World", @"^H[A-Za-z]+$" );
// Convert To String
Assert.IsNull( (string)match, "Unsuccessful match should convert to null." );
// The following will always fail - C# does the null check without converting
// if (match != null) { Assert.Fail("..."); }
}
[TestMethod]
public void SuccessfulCapturesAccessedAsProperties( )
{
var match = DynamicRegex.Match( "123-456-7890", @"(?\d{3})-?(?\d{3})-?(\d{4})" );
Assert.AreEqual( "123", match.npa, "Capture group npa could not be accessed by name." );
Assert.AreEqual( "456", match.nxx, "Capture group nxx could not be accessed by name." );
}
[TestMethod]
public void SuccessfulCapturesAccessedAsIndexer( )
{
var match = DynamicRegex.Match( "123-456-7890", @"(?\d{3})-?(?\d{3})-?(\d{4})" );
Assert.AreEqual( "123", match["npa"], "Capture group npa could not be accessed by name." );
Assert.AreEqual( "456", match["nxx"], "Capture group nxx could not be accessed by name." );
Assert.AreEqual( "7890", match[1], "Unnamed capture group could not be accessed by number." );
Assert.AreEqual( "123-456-7890", match[0], "Capture group 0 should return entire match." );
}
[TestMethod]
public void UnsuccessfulMatchReturnsPropertiesAsNull( )
{
// Must match 123-456-7890
// Last 4 digits will cause unsuccessful match
var match = DynamicRegex.Match( "123-456-XXXX", @"(?\d{3})-?(?\d{3})-?(\d{4})" );
Assert.IsNull( match.npa, "Unsuccessful match must return all properties as null." );
Assert.IsNull( match["npa"], "Unsuccessful match must return all properties as null." );
Assert.IsNull( match["1"], "Unsuccessful match must return all properties as null." );
Assert.IsNull( match["0"], "Unsuccessful match must return all properties as null." );
}
[TestMethod]
public void SuccessfulMatchReturnsOptionalGroupAsNull( )
{
// Must match at least 123-456-
// Last 4 digits are optional
var match = DynamicRegex.Match( "123-456-XXXX", @"(?\d{3})-?(?\d{3})-?(\d{4})?" );
Assert.AreEqual( "123", match["npa"], "Successful match should return captured groups as string." );
Assert.AreEqual( "456", match["nxx"], "Successful match should return captured groups as string." );
Assert.AreEqual( "123-456-", match[0], "Successful match should return entire match as string." );
Assert.IsNull( match[1], "Successful match should return missing optional groups as null." );
}
}


I have been using this Visual Studio extension called