Simple C# Calculator Tutorial: From Console to GUI

Lightweight and Simple C# Calculator — Source Code IncludedA compact calculator application is a perfect first project for C# learners and a useful utility for experienced developers who want a small, reusable component. This article walks through the design, implementation, and extension of a lightweight and simple C# calculator. It includes full source code for a console version and a minimal Windows Forms GUI version, explanations of the core arithmetic engine, suggestions for testing and extending the project, and tips for clean, maintainable code.


Why build a lightweight calculator?

A calculator encapsulates several important programming concepts in a small scope:

  • Input parsing and validation
  • Separation of logic (model) and UI (view/controller)
  • Exception handling and edge cases
  • Unit testing of pure functions

Because the requirements are limited (basic arithmetic), you can focus on writing clean, testable code and experimenting with features such as expression parsing, operator precedence, and a simple UI.


Design overview

We’ll build two versions:

  1. A console calculator that evaluates expressions typed by the user (supports +, -, *, /, parentheses, and unary minus).
  2. A minimal Windows Forms GUI calculator with buttons for digits and operators, using the same arithmetic engine.

Core design goals:

  • Keep the arithmetic engine independent from UI.
  • Use a simple tokenizer + shunting-yard parser to handle operator precedence.
  • Provide clear, well-documented code suitable for beginners.
  • Handle errors gracefully and avoid crashes.

Core arithmetic engine

We’ll implement a tokenizer, convert infix expressions to Reverse Polish Notation (RPN) via the shunting-yard algorithm, and evaluate the RPN. This keeps the engine small but powerful enough to support parentheses and operator precedence.

Key features:

  • Operators: +, -, *, /
  • Support for decimal numbers
  • Unary minus (e.g., -5, 2 * -3)
  • Proper error messages for malformed expressions and division by zero

Tokenizer (concept)

  • Read characters; group digits and decimal points into number tokens.
  • Treat + – * / ( ) as separate tokens.
  • Detect unary minus when a minus appears at the start or after another operator or ‘(’.

Console version — Full source code

// File: Program.cs using System; using System.Collections.Generic; using System.Globalization; namespace SimpleCalculator {     enum TokenType { Number, Operator, LeftParen, RightParen }     record Token(TokenType Type, string Value);     class Calculator     {         static readonly HashSet<string> Operators = new() { "+", "-", "*", "/" };         static readonly Dictionary<string, int> Precedence = new()         {             { "+", 1 }, { "-", 1 }, { "*", 2 }, { "/", 2 }         };         public static List<Token> Tokenize(string input)         {             var tokens = new List<Token>();             int i = 0;             while (i < input.Length)             {                 char c = input[i];                 if (char.IsWhiteSpace(c)) { i++; continue; }                 if (char.IsDigit(c) || c == '.')                 {                     int start = i;                     while (i < input.Length && (char.IsDigit(input[i]) || input[i] == '.')) i++;                     string num = input[start..i];                     tokens.Add(new Token(TokenType.Number, num));                     continue;                 }                 if (c == '(') { tokens.Add(new Token(TokenType.LeftParen, "(")); i++; continue; }                 if (c == ')') { tokens.Add(new Token(TokenType.RightParen, ")")); i++; continue; }                 string s = c.ToString();                 if (Operators.Contains(s))                 {                     // detect unary minus                     if (s == "-" &&                         (tokens.Count == 0 ||                          tokens[^1].Type == TokenType.Operator ||                          tokens[^1].Type == TokenType.LeftParen))                     {                         // parse a unary negative number                         i++;                         // read number following unary minus                         int start = i;                         if (i < input.Length && (char.IsDigit(input[i]) || input[i] == '.'))                         {                             while (i < input.Length && (char.IsDigit(input[i]) || input[i] == '.')) i++;                             string num = "-" + input[start..i];                             tokens.Add(new Token(TokenType.Number, num));                             continue;                         }                         else                         {                             // treat unary minus as negative sign before an expression, push 0 and binary -                             tokens.Add(new Token(TokenType.Number, "0"));                             tokens.Add(new Token(TokenType.Operator, "-"));                             continue;                         }                     }                     tokens.Add(new Token(TokenType.Operator, s));                     i++;                     continue;                 }                 throw new ArgumentException($"Unexpected character '{c}' at position {i}.");             }             return tokens;         }         public static Queue<Token> ToRpn(List<Token> tokens)         {             var output = new Queue<Token>();             var ops = new Stack<Token>();             foreach (var token in tokens)             {                 if (token.Type == TokenType.Number)                 {                     output.Enqueue(token);                 }                 else if (token.Type == TokenType.Operator)                 {                     while (ops.Count > 0 && ops.Peek().Type == TokenType.Operator &&                            Precedence[ops.Peek().Value] >= Precedence[token.Value])                     {                         output.Enqueue(ops.Pop());                     }                     ops.Push(token);                 }                 else if (token.Type == TokenType.LeftParen)                 {                     ops.Push(token);                 }                 else if (token.Type == TokenType.RightParen)                 {                     while (ops.Count > 0 && ops.Peek().Type != TokenType.LeftParen)                         output.Enqueue(ops.Pop());                     if (ops.Count == 0 || ops.Peek().Type != TokenType.LeftParen)                         throw new ArgumentException("Mismatched parentheses.");                     ops.Pop(); // remove left paren                 }             }             while (ops.Count > 0)             {                 var t = ops.Pop();                 if (t.Type == TokenType.LeftParen || t.Type == TokenType.RightParen)                     throw new ArgumentException("Mismatched parentheses.");                 output.Enqueue(t);             }             return output;         }         public static double EvaluateRpn(Queue<Token> rpn)         {             var stack = new Stack<double>();             while (rpn.Count > 0)             {                 var token = rpn.Dequeue();                 if (token.Type == TokenType.Number)                 {                     if (!double.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val))                         throw new ArgumentException($"Invalid number '{token.Value}'.");                     stack.Push(val);                 }                 else if (token.Type == TokenType.Operator)                 {                     if (stack.Count < 2) throw new ArgumentException("Malformed expression.");                     double b = stack.Pop();                     double a = stack.Pop();                     double res = token.Value switch                     {                         "+" => a + b,                         "-" => a - b,                         "*" => a * b,                         "/" => b == 0 ? throw new DivideByZeroException("Division by zero.") : a / b,                         _ => throw new ArgumentException($"Unknown operator '{token.Value}'.")                     };                     stack.Push(res);                 }             }             if (stack.Count != 1) throw new ArgumentException("Malformed expression.");             return stack.Pop();         }         public static double Evaluate(string input)         {             var tokens = Tokenize(input);             var rpn = ToRpn(tokens);             return EvaluateRpn(rpn);         }     }     class Program     {         static void Main()         {             Console.WriteLine("Simple C# Calculator. Type 'exit' to quit.");             while (true)             {                 Console.Write("> ");                 string? line = Console.ReadLine();                 if (line == null) break;                 line = line.Trim();                 if (line.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;                 if (line.Length == 0) continue;                 try                 {                     double result = Calculator.Evaluate(line);                     Console.WriteLine(result.ToString(CultureInfo.InvariantCulture));                 }                 catch (Exception ex)                 {                     Console.WriteLine($"Error: {ex.Message}");                 }             }         }     } } 

Minimal Windows Forms GUI version

This GUI uses the same Calculator.Evaluate method. It demonstrates separation of concerns: UI handles input and display; engine evaluates expressions.

  • Create a WinForms .NET Framework or .NET 6+ project.
  • Add a single TextBox (txtDisplay) and Buttons for digits 0–9, operators + – * /, equals (=), clear ©, and parentheses.

Example event handler for the “=” button:

private void btnEquals_Click(object sender, EventArgs e) {     try     {         var result = Calculator.Evaluate(txtDisplay.Text);         txtDisplay.Text = result.ToString(CultureInfo.InvariantCulture);     }     catch (Exception ex)     {         MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);     } } 

Wire digit/operator buttons to append their text to txtDisplay; the Clear button should set txtDisplay.Text = “”.


Testing and edge cases

  • Unit tests should cover:

    • Simple operations: “1+2”, “3*4”, “⁄2
    • Operator precedence: “2+3*4” => 14
    • Parentheses: “(2+3)*4” => 20
    • Unary minus: “-5+3”, “2*-3”
    • Division by zero throws DivideByZeroException
    • Malformed inputs produce ArgumentException
  • Edge cases:

    • Multiple decimal points in a number (“1.2.3”) — tokenizer will allow this as a string and parsing will fail with a clear error.
    • Leading plus sign (“+5”) — currently treated as binary operator; you can extend tokenizer to accept unary plus.

Extending the calculator

Ideas to expand functionality:

  • Add exponentiation (^) and adjust precedence and associativity.
  • Add functions: sin, cos, tan, sqrt, log (requires function tokens and unary function handling).
  • Implement a tokenizer that supports variables and assignment (e.g., “a=5”, “a*2”).
  • Replace the shunting-yard parser with a recursive descent parser for clearer grammar handling.
  • Provide history and persistent storage of previous calculations in the GUI.

Clean code tips

  • Keep the arithmetic engine pure and free of UI concerns to make unit testing trivial.
  • Validate inputs early and give specific error messages.
  • Use small functions and meaningful names; favor immutability where practical.
  • Add XML documentation comments to public methods for IDE help.

Summary

This lightweight C# calculator demonstrates parsing, operator precedence, and separation of logic from UI. The provided console source gives a complete, working engine; the GUI example shows how to reuse that engine in a Windows Forms app. The architecture is small, testable, and easy to extend with more operators or functions.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *