Functions and Methods in C#
Learn methods, ref/out parameters, optional parameters, local functions, and expression-bodied members.
Defining Methods
Methods are named, reusable blocks of logic. They are how you break a program into understandable pieces, avoid repeating code, and give names to operations. Every method declares what it returns (or void for nothing), what it is called, and what inputs it needs.
public class MathHelper
{
// Basic method — takes two ints, returns their sum
public int Add(int a, int b)
{
return a + b;
}
// Void method — performs an action, returns nothing
public void PrintSquare(int n)
{
Console.WriteLine(n * n);
}
// Static method — belongs to the class, not an instance; call via MathHelper.CircleArea(...)
public static double CircleArea(double radius)
{
return Math.PI * radius * radius;
}
}
var helper = new MathHelper();
Console.WriteLine(helper.Add(3, 4)); // 7
helper.PrintSquare(5); // 25
Console.WriteLine(MathHelper.CircleArea(3)); // 28.27...
Expression-Bodied Members
When a method or property body is a single expression, the => syntax eliminates the braces and return, reducing noise. This is especially common for simple computed properties and one-liner methods that appear frequently in C# code.
public class Calculator
{
// Expression-bodied method — cleaner than a full block with return
public int Multiply(int a, int b) => a * b;
// Expression-bodied read-only property
public double Pi => Math.PI;
// Expression-bodied getter and setter on the same property
private string _name = "";
public string Name
{
get => _name;
set => _name = value ?? throw new ArgumentNullException(nameof(value));
}
// Expression-bodied constructor
public Calculator(string name) => _name = name;
}
Parameters and Arguments
Value Parameters (default)
By default, C# passes arguments by value — the method receives a copy. Modifying the copy does not affect the caller’s original variable. This is safe and predictable for most use cases.
void Double(int n)
{
n *= 2; // only modifies the local copy — caller's variable unchanged
}
int x = 5;
Double(x);
Console.WriteLine(x); // 5 — unchanged
ref Parameters
ref passes the variable by reference, meaning the method works directly with the caller’s storage. Changes in the method are visible to the caller. The variable must be initialized before being passed.
void DoubleRef(ref int n)
{
n *= 2; // modifies the caller's variable directly
}
int x = 5;
DoubleRef(ref x);
Console.WriteLine(x); // 10
// Classic use-case: swap two variables without a helper class
void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int p = 1, q = 2;
Swap(ref p, ref q);
Console.WriteLine($"p={p}, q={q}"); // p=2, q=1
out Parameters
out parameters must be assigned by the method before it returns. Unlike ref, the caller does not need to initialize the variable first. This pattern is used throughout the BCL for TryParse-style methods that return both a success flag and a result.
bool TryDivide(int a, int b, out double result)
{
if (b == 0)
{
result = 0; // must assign before returning, even on failure path
return false;
}
result = (double)a / b;
return true;
}
if (TryDivide(10, 3, out double quotient))
Console.WriteLine($"Result: {quotient:F2}"); // 3.33
// Inline declaration (C# 7+) — declare the variable right at the call site
if (int.TryParse("42", out int parsed))
Console.WriteLine(parsed);
// Discard the out parameter when you only care about the success/failure
int.TryParse("42", out _);
params — Variable-Length Arguments
params lets a method accept any number of arguments of the same type. The caller passes them as a comma-separated list, and the method receives them as an array. This avoids forcing callers to construct an array just to call the method.
int Sum(params int[] numbers)
{
int total = 0;
foreach (int n in numbers)
total += n;
return total;
}
Console.WriteLine(Sum(1, 2, 3)); // 6 — caller passes individual args
Console.WriteLine(Sum(1, 2, 3, 4, 5)); // 15
Console.WriteLine(Sum(new int[] { 10, 20 })); // 30 — passing an array also works
Optional Parameters and Named Arguments
Optional parameters have default values and can be omitted by callers. Named arguments let callers specify which parameter they are providing by name, which is especially useful when a method has many optional parameters and you only want to set one of the later ones.
string FormatName(string first, string last, string title = "", bool formal = false)
{
if (formal && !string.IsNullOrEmpty(title))
return $"{title} {last}, {first}";
return string.IsNullOrEmpty(title)
? $"{first} {last}"
: $"{title} {first} {last}";
}
Console.WriteLine(FormatName("Alice", "Smith")); // Alice Smith
Console.WriteLine(FormatName("Alice", "Smith", "Dr.")); // Dr. Alice Smith
Console.WriteLine(FormatName("Alice", "Smith", "Dr.", formal: true)); // Dr. Smith, Alice
// Named arguments — skip to the parameter you care about, in any order
Console.WriteLine(FormatName(last: "Smith", first: "Alice", formal: true, title: "Prof."));
Method Overloading
Overloading lets multiple methods share the same name with different parameter signatures. The compiler picks the right one based on the arguments you pass. This makes APIs feel natural — callers call Print regardless of what they are printing, and the compiler routes it correctly.
public class Printer
{
// Each overload handles a different input type
public void Print(string text)
=> Console.WriteLine(text);
public void Print(int number)
=> Console.WriteLine(number.ToString());
public void Print(string text, ConsoleColor color)
{
Console.ForegroundColor = color;
Console.WriteLine(text);
Console.ResetColor();
}
}
Local Functions
A local function is a method defined inside another method. It is only visible within the enclosing method, which keeps implementation details private without cluttering the class with internal helper methods. Local functions can close over the outer method’s variables, making them ideal for recursive helpers and callbacks.
public long Factorial(int n)
{
if (n < 0) throw new ArgumentOutOfRangeException(nameof(n));
return Calculate(n);
// Local function — totally private to Factorial, invisible outside
long Calculate(int x) => x <= 1 ? 1 : x * Calculate(x - 1);
}
// Local function closing over outer variables — IsValid can see minValue
public IEnumerable<int> GetValidIds(List<int> candidates, int minValue)
{
var results = new List<int>();
foreach (int id in candidates)
if (IsValid(id)) results.Add(id);
return results;
bool IsValid(int id) => id > minValue && id < int.MaxValue;
}
static local functions
Marking a local function static prevents it from accidentally capturing outer variables. This is a good practice for performance-sensitive code — it makes it impossible to introduce a closure allocation by mistake.
public double ComputeScore(double[] values)
{
double sum = 0;
foreach (double v in values)
sum += Normalize(v);
return sum / values.Length;
// static prevents this function from capturing 'sum' or 'values' by accident
static double Normalize(double v) => v / 100.0;
}
Tuples as Return Types
Sometimes a method naturally produces multiple related values. Before tuples, you had to create a class or use out parameters. Named tuples give you a lightweight, readable alternative without any boilerplate.
// Named tuple return — callers get meaningful property names
(double Min, double Max, double Average) Analyze(double[] data)
{
double min = data.Min();
double max = data.Max();
double avg = data.Average();
return (min, max, avg);
}
var stats = Analyze(new double[] { 1, 5, 3, 8, 2 });
Console.WriteLine($"Min={stats.Min}, Max={stats.Max}, Avg={stats.Average}");
// Deconstruct on call — unpack directly into named variables
var (min, max, avg) = Analyze(new double[] { 1, 5, 3 });
Extension Methods
Extension methods let you add new methods to existing types — including types you do not own — without subclassing or modifying the original source. They appear as instance methods on the target type in IntelliSense and LINQ is built entirely on this mechanism.
// The 'this' parameter specifies the type being extended
public static class StringExtensions
{
public static bool IsNullOrEmpty(this string? s) =>
string.IsNullOrEmpty(s);
public static string Truncate(this string s, int maxLength) =>
s.Length <= maxLength ? s : s[..maxLength] + "...";
public static string ToTitleCase(this string s) =>
System.Globalization.CultureInfo.CurrentCulture
.TextInfo.ToTitleCase(s.ToLower());
}
// Usage — looks exactly like a built-in method on string
string text = "hello world this is a long sentence";
Console.WriteLine(text.Truncate(15)); // hello world th...
Console.WriteLine(text.ToTitleCase()); // Hello World This Is A Long Sentence
Console.WriteLine("".IsNullOrEmpty()); // True
Recursive Methods
Recursion solves problems that decompose naturally into smaller versions of themselves. The key requirement is a base case that stops the recursion. For performance, consider memoization to avoid recalculating the same sub-problems repeatedly.
// Fibonacci with memoization — avoids the exponential time of naive recursion
public static long Fib(int n, Dictionary<int, long>? memo = null)
{
memo ??= new Dictionary<int, long>(); // create on first call
if (n <= 1) return n; // base cases
if (memo.TryGetValue(n, out long cached)) return cached; // reuse if computed
memo[n] = Fib(n - 1, memo) + Fib(n - 2, memo);
return memo[n];
}
Console.WriteLine(Fib(50)); // 12586269025