user15741
user15741

Reputation: 1412

How to get behavior like String.Format with Interpolated [Named] values at runtime?

I have a class of allowed substitution values:

class MaskDictionary
{
    public int id { get; set; }
    public string last { get; set; }
    public string lastinitial { get; set; }
    public string first { get; set; }
    public string firstinitial { get; set; }
    public string salutation { get; set; }
    public DateTime today { get; set; }
}

and I want to take a formatting string as user input, like:

string userFormat = "{last}, {first} {today}";

and generate the interpolated value. Conceptually similar to:

string.Format("{last}, {first} {today}", MaskDictionary);

but making the input string dynamic fails:

string.Format(userFormat, MaskDictionary);

What is a simple, clean way to provide runtime formatting?

There are some clunky options that use reflection and recursive replaces, like

        string userFormat = "{last}, {first} {today}";
        PropertyInfo[] properties = typeof(MaskDictionary).GetProperties();
        foreach (PropertyInfo property in properties)
        {
            userFormat = string.Replace(property.name, property.GetValue(mask));
        }

but there has to be a better way.

--update with comparison of answers--

I tested the two proposed solutions in the answers for performance and got pretty surprising results.

  static class Format2
    {
        static public string Format(string format, MaskDictionary md)
        {
            string val = format;
            foreach (PropertyInfo property in typeof(MaskDictionary).GetProperties())
            {
                val = val.Replace("{" + property.Name + "}", property.GetValue(md).ToString());
            }
            return val;
        }
    }
static class Format1
{
    public static string FormatWith(this string format, IFormatProvider provider, object source)
    {
        if (format == null)
            throw new ArgumentNullException("format");

        Regex r = new Regex(@"(?<start>\{)+(?<property>[\w\.\[\]]+)(?<format>:[^}]+)?(?<end>\})+",
          RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);

        List<object> values = new List<object>();
        string rewrittenFormat = r.Replace(format, delegate (Match m)
        {
            Group startGroup = m.Groups["start"];
            Group propertyGroup = m.Groups["property"];
            Group formatGroup = m.Groups["format"];
            Group endGroup = m.Groups["end"];

            values.Add((propertyGroup.Value == "0")
              ? source
              : DataBinder.Eval(source, propertyGroup.Value));

            return new string('{', startGroup.Captures.Count) + (values.Count - 1) + formatGroup.Value
              + new string('}', endGroup.Captures.Count);
        });

        return string.Format(provider, rewrittenFormat, values.ToArray());
    }
}

The Regex solution is slower, much slower. Using 1000 iterations of a short format string (20 characters, 3 replacements) with a 5 property dictionary object and a 105 property dictionary object, and a long format string (2000 characters, 3 replacements) and a long dictionary object, I got to following results:

Short format, small dictionary
Regex - 2150 ms
Replace - 3 ms
Short format, large dictionary
Regex - 2160 ms
Replace - 30 ms
Long format, short dictionary
Regex - 2170 ms
Replace - 26 ms
Long format, large dictionary
Regex - 2250 ms
Replace - 330 ms

Replace doesn't scale as well with a large dictionary, but it starts so much faster that it takes a large dictionary plus a very long format string to be slower. With the 105 property dictionary, it took about 16,000 character format string to take the same amount of time to process, ~2500ms. With the 5 property small dictionary, the regex was never as fast. A 600K character format string took 14000 ms for regex and 7000 ms for replace and a 1.7M character format string took 38000 ms vs 21000 ms. Replace wins as long as the dictionary object is reasonably sized and the format string is shorter than 80 pages.

Upvotes: 2

Views: 858

Answers (4)

Ryan
Ryan

Reputation: 2020

I have a library called FormatWith that does this with string extension methods (FormatWith()). It's similar to James Newton King's implementation, but has some advantages:

  • No regex. The extensions use a state machine parser to handle the input string, which is faster and handles escaped brackets correctly.

  • Does not rely on DataBinder. DataBinder is not available on .NET Core and is also dangerous when used with unsanitized input.

  • Works with anything that implements .NET Standard 2.0, so it can be used in .NET Core and ASP.NET Core applications.

Upvotes: 2

smead
smead

Reputation: 1808

You could convert your code into a ToString(...) function:

using System.Reflection;
using System.ComponentModel;
class MaskDictionary
{
    // ... properties ...

    public string ToString(string format)
    {
        string val = format;
        foreach (PropertyInfo property in typeof(MaskDictionary).GetProperties())
        {
            val = val.Replace("{" + property.Name + "}", property.GetValue(this).ToString());
        }
        return val;
    }
}

Edit: here's a version that lets you rename the user format tags without renaming the property:

class MaskDictionary
{
    // ... properties ...
    [DisplayName("bar")]
    public string foo {get;set;}
    public int baz {get;set;}

    public string ToString(string format)
    {
        string val = format;
        foreach (PropertyInfo property in typeof(MaskDictionary).GetProperties())
        {
            var dispAttr = (DisplayNameAttribute)Attribute.GetCustomAttribute(property, typeof(DisplayNameAttribute));
            string pName = dispAttr != null ? dispAttr.DisplayName : property.Name;
            val = val.Replace("{" + pName + "}", property.GetValue(this).ToString());
        }
        return val;
    }
}

usage:

var m = new MaskDictionary();
m.foo = "hello";
m.baz = 111;
Console.WriteLine(m.ToString("{foo} {bar} {baz}"));
//output: {foo} hello 111

Upvotes: 1

Rion Williams
Rion Williams

Reputation: 76547

James Newton King (the JSON guy) uses a FormatWith() extension method defined in this blog post that would essentially accomplish what you are attempting to do :

public static string FormatWith(this string format, IFormatProvider provider, object source)
{
  if (format == null)
    throw new ArgumentNullException("format");

  Regex r = new Regex(@"(?<start>\{)+(?<property>[\w\.\[\]]+)(?<format>:[^}]+)?(?<end>\})+",
    RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);

  List<object> values = new List<object>();
  string rewrittenFormat = r.Replace(format, delegate(Match m)
  {
    Group startGroup = m.Groups["start"];
    Group propertyGroup = m.Groups["property"];
    Group formatGroup = m.Groups["format"];
    Group endGroup = m.Groups["end"];

    values.Add((propertyGroup.Value == "0")
      ? source
      : DataBinder.Eval(source, propertyGroup.Value));

    return new string('{', startGroup.Captures.Count) + (values.Count - 1) + formatGroup.Value
      + new string('}', endGroup.Captures.Count);
  });

  return string.Format(provider, rewrittenFormat, values.ToArray());
}

It basically relies on Regular Expressions along with the .NET Databinder class to handle performing the actual matching and replacements.

Upvotes: 2

D Stanley
D Stanley

Reputation: 152556

There's nothing in the framework, but there's a clever extension method that you could use to inject properties into your format string by name:

string result = "{last}, {first} {today}".FormatWith(MaskDictionary);

The closest you can get woithout an extension is to use string interpolation in C#6:

string result = $"{MaskDictionary.last}, {MaskDictionary.first} {MaskDictionary.today}";

Upvotes: 1

Related Questions