Have you ever had to clean up your users’ input, only to realize the pain and aggravation it can lead to with all the unnecessary memory overhead? I recently had an “aha!” moment and thought I would share this tip with you.

This article will be short, but I’ll try to give you some context as we go along. Let’s get started.

Calculators and User Input

I recently looked at a sample calculator project from MAUI developer Naweed Akram. He has some fantastic MAUI samples, and his Scientific Calculator is impressive. Check it out, seriously. He uses the NCalc library to parse string expressions and evaluate the result. It’s a very cool library but has a caveat. Functions are case-sensitive, so user input must be constrained or normalized. Naweed chose the normalization path, and his implementation is similar to the approach I would have taken myself. Let’s take a look at it.

private string NormalizeInputString()
{
    Dictionary<string, string> _opMapper = new()
    {
        {"×", "*"},
        {"÷", "/"},
        {"SIN", "Sin"},
        {"COS", "Cos"},
        {"TAN", "Tan"},
        {"ASIN", "Asin"},
        {"ACOS", "Acos"},
        {"ATAN", "Atan"},
        {"LOG", "Log"},
        {"EXP", "Exp"},
        {"LOG10", "Log10"},
        {"POW", "Pow"},
        {"SQRT", "Sqrt"},
        {"ABS", "Abs"},
    };
    
    var retString = InputText;
    foreach (var key in _opMapper.Keys)
    {
        retString = retString.Replace(key, _opMapper[key]);
    }

    return retString;
}

The method uses a Dictionary of keys and values to force specific casing to match what NCalc expects from the user. While functional, there is room to optimize performance.

In the previous code, the method call to Replace creates a new string in memory for each replacement. So, in a worst-case scenario, we could have 15 new strings allocated as we clean the user’s equation, one for each key and the original value. Yikes!

What should we do?!

Using StringBuilder To Replace

We commonly think of StringBuilder as a class to build up new string values, but it didn’t occur to me that StringBuilder could also be used to alter an existing string efficiently. So, let’s update the code sample.

private string NormalizeInputString()
{
    Dictionary<string, string> _opMapper = new()
    {
        {"×", "*"},
        {"÷", "/"},
        {"SIN", "Sin"},
        {"COS", "Cos"},
        {"TAN", "Tan"},
        {"ASIN", "Asin"},
        {"ACOS", "Acos"},
        {"ATAN", "Atan"},
        {"LOG", "Log"},
        {"EXP", "Exp"},
        {"LOG10", "Log10"},
        {"POW", "Pow"},
        {"SQRT", "Sqrt"},
        {"ABS", "Abs"},
    };
    
    var retString = new StringBuilder(InputText);
    foreach (var key in _opMapper.Keys)
    {
        retString.Replace(key, _opMapper[key]);
    }

    return retString.ToString();
}

Now we only generate two strings: the original input and the final result. You might be asking, “how much of a difference can this make, really?”. Well, every optimization is contextual and will depend on your use case. But, we can reduce some unnecessary memory pressure for a relatively simple change.

Let’s use Benchmarkdotnet to compare the previous implementation and the StringBuilder approach.

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<StringReplace>();

[MemoryDiagnoser]
[ShortRunJob]
public class StringReplace
{
    readonly Dictionary<string, string> map = new()
    {
        { "×", "*" },
        { "÷", "/" },
        { "SIN", "Sin" },
        { "COS", "Cos" },
        { "TAN", "Tan" },
        { "ASIN", "Asin" },
        { "ACOS", "Acos" },
        { "ATAN", "Atan" },
        { "LOG", "Log" },
        { "EXP", "Exp" },
        { "LOG10", "Log10" },
        { "POW", "Pow" },
        { "SQRT", "Sqrt" },
        { "ABS", "Abs" },
    };

    private const string target = "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |" +
                                  "1 x 1 ÷ 1 * SIN(COS(TAN(LOG(ASIN(ATAN(EXP(POW(SQRT(ABS(1))))))))))  |";

    [Benchmark]
    public string String()
    {
        var result = target;
        foreach (var key in map.Keys)
        {
            result = result.Replace(key, map[key]);
        }

        return result;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var result = new StringBuilder(target);
        foreach (var key in map.Keys)
        {
            result.Replace(key, map[key]);
        }

        return result.ToString();
    }
}

Let’s see the results of the Benchmarkdotnet run.

|        Method |     Mean |    Error |    StdDev |   Gen0 | Allocated |
|-------------- |---------:|---------:|----------:|-------:|----------:|
|        String | 3.967 us | 1.673 us | 0.0917 us | 1.8082 |  11.11 KB |
| StringBuilder | 3.480 us | 2.472 us | 0.1355 us | 0.4082 |   2.52 KB |

Wow! The StringBuilder implementation allocates about 20% of what the String implementation does. That’s a pretty significant performance optimization for code that looks almost identical. These increases can become more effective as the target string grows or the dictionary of keys/values expands.

Conclusion

While you may think of StringBuilder as a means to build strings over several iterations, you can also think of StringBuilder as an alteration tool. Utilizing the Replace method could reduce memory overhead while keeping the code almost the same. Given the benchmark comparison, you can also see reduced Gen0 sizes, which should minimize garbage collection pauses over time. It’s a win-win.

I hope you enjoyed this blog post, and please feel free to share it with colleagues and friends. As always, thanks for reading.