C# · lesson 7 of 26
Functions and Methods in C#
C# methods by example: value, ref, out and params parameters, optional and named arguments, overloading, local functions, tuples and extension methods.
By SkillByExample · Updated
Defining Methods
A method declares its return type (or void), its name, and its parameters. Instance methods need an object; static methods belong to the type.
var helper = new MathHelper();
Console.WriteLine(helper.Add(3, 4));
helper.PrintSquare(5);
Console.WriteLine(MathHelper.CircleArea(3));
Console.WriteLine(MathHelper.CircleArea(3).ToString("F2"));
public class MathHelper
{
public int Add(int a, int b)
{
return a + b;
}
public void PrintSquare(int n)
{
Console.WriteLine(n * n);
}
public static double CircleArea(double radius)
{
return Math.PI * radius * radius;
}
}
$ dotnet run
7
25
28.274333882308138
28.27
The class sits below the calls: in a file with top-level statements, type declarations must come after the statements, or the build fails with CS8803. A double prints with full round-trip precision by default, so format it ("F2") when showing it to a person.
Expression-Bodied Members
When a member’s body is a single expression, => replaces the braces and return.
var calc = new Calculator("casio");
Console.WriteLine(calc.Multiply(6, 7));
Console.WriteLine(calc.Name);
try
{
calc.Name = null!;
}
catch (ArgumentNullException ex)
{
Console.WriteLine(ex.Message);
}
public class Calculator
{
private string _name;
public Calculator(string name) => _name = name;
public int Multiply(int a, int b) => a * b;
public string Name
{
get => _name;
set => _name = value ?? throw new ArgumentNullException(nameof(value));
}
}
$ dotnet run
42
casio
Value cannot be null. (Parameter 'value')
Constructor, method, getter and setter are all one-liners. ?? throw lets the setter reject null inside a single expression; the exception message names the parameter, value, because that is what nameof(value) produced.
Value, ref and out Parameters
By default a method receives a copy of each argument. ref hands it the caller’s variable itself, and out makes the method responsible for assigning it.
int x = 5;
Double(x);
Console.WriteLine($"after Double: {x}");
DoubleRef(ref x);
Console.WriteLine($"after DoubleRef: {x}");
int p = 1, q = 2;
Swap(ref p, ref q);
Console.WriteLine($"p={p}, q={q}");
if (TryDivide(10, 3, out double quotient))
Console.WriteLine($"10/3 = {quotient:F2}");
Console.WriteLine(TryDivide(1, 0, out _));
Console.WriteLine(int.TryParse("4x2", out int parsed) + " " + parsed);
void Double(int n) => n *= 2;
void DoubleRef(ref int n) => n *= 2;
void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);
bool TryDivide(int a, int b, out double result)
{
result = b == 0 ? 0 : (double)a / b;
return b != 0;
}
$ dotnet run
after Double: 5
after DoubleRef: 10
p=2, q=1
10/3 = 3.33
False
False 0
Double changed its own copy and threw it away, so x is still 5; DoubleRef wrote through to x. out _ discards a value you don’t need. A failed int.TryParse still assigns its out parameter — to 0 — which is why you must check the returned bool rather than the number.
Objects Are Passed by Reference-Copy
For a class, the thing copied is the reference: the method can change the object, but not which object the caller holds.
var list = new List<int> { 1 };
AddItem(list);
Replace(list);
Console.WriteLine(string.Join(", ", list));
ReplaceRef(ref list);
Console.WriteLine(string.Join(", ", list));
void AddItem(List<int> l) => l.Add(2);
void Replace(List<int> l) => l = new List<int> { 99 };
void ReplaceRef(ref List<int> l) => l = new List<int> { 99 };
$ dotnet run
1, 2
99
AddItem mutated the shared list, so 2 is visible. Replace pointed its local copy of the reference at a new list, which the caller never sees. Only with ref does reassignment reach the caller’s variable.
params, Optional and Named Arguments
params collects any number of arguments into an array; optional parameters have defaults; named arguments pick a parameter by name.
Console.WriteLine(Sum());
Console.WriteLine(Sum(1, 2, 3));
Console.WriteLine(Sum(new[] { 10, 20 }));
Console.WriteLine(FormatName("Alice", "Smith"));
Console.WriteLine(FormatName("Alice", "Smith", "Dr."));
Console.WriteLine(FormatName("Alice", "Smith", "Dr.", formal: true));
Console.WriteLine(FormatName(last: "Smith", first: "Alice", formal: true, title: "Prof."));
int Sum(params int[] numbers) => numbers.Sum();
string FormatName(string first, string last, string title = "", bool formal = false)
{
if (formal && title != "")
return $"{title} {last}, {first}";
return title == "" ? $"{first} {last}" : $"{title} {first} {last}";
}
$ dotnet run
0
6
30
Alice Smith
Dr. Alice Smith
Dr. Smith, Alice
Prof. Smith, Alice
Sum() with no arguments is legal and gets an empty array, not null. Named arguments can appear in any order, which is what makes formal: true readable without passing every earlier optional parameter.
Method Overloading
Several methods can share a name if their parameter lists differ; the compiler picks one from the argument types.
var p = new Printer();
p.Print("text");
p.Print(42);
p.Print(42L);
p.Print(3.5);
p.Print('x');
public class Printer
{
public void Print(string text) => Console.WriteLine($"string: {text}");
public void Print(int number) => Console.WriteLine($"int: {number}");
public void Print(double number) => Console.WriteLine($"double: {number}");
}
$ dotnet run
string: text
int: 42
double: 42
double: 3.5
int: 120
There is no long or char overload, yet both calls compile. The compiler picks the best implicit conversion: 42L widens to double (a long does not fit in int implicitly), and 'x' widens to int, printing its character code 120.
Local Functions
A local function lives inside another method. It can capture the outer method’s variables — unless it is marked static, which forbids that.
Console.WriteLine(Factorial(10));
Console.WriteLine(string.Join(", ", ValidIds(new List<int> { 3, 8, 12, 1 }, minValue: 5)));
Console.WriteLine(Average(new[] { 50.0, 100.0 }));
long Factorial(int n)
{
if (n < 0) throw new ArgumentOutOfRangeException(nameof(n));
return Calculate(n);
static long Calculate(int x) => x <= 1 ? 1 : x * Calculate(x - 1);
}
List<int> ValidIds(List<int> candidates, int minValue)
{
return candidates.Where(IsValid).ToList();
bool IsValid(int id) => id > minValue;
}
double Average(double[] values)
{
return values.Sum(Normalize) / values.Length;
static double Normalize(double v) => v / 100.0;
}
$ dotnet run
3628800
8, 12
0.75
Factorial validates its argument once and leaves the recursion to Calculate, so the check does not rerun on every level. IsValid reads minValue from the enclosing method — that is a closure. Normalize is static; if it tried to touch values, the build would fail, which guarantees it never allocates a closure.
Tuples as Return Types
A named tuple returns several values without declaring a class.
var stats = Analyze(new double[] { 1, 5, 3, 8, 2 });
Console.WriteLine($"Min={stats.Min}, Max={stats.Max}, Avg={stats.Average}");
Console.WriteLine(stats);
var (min, max, _) = Analyze(new double[] { 1, 5, 3 });
Console.WriteLine($"{min}..{max}");
(double Min, double Max, double Average) Analyze(double[] data)
=> (data.Min(), data.Max(), data.Average());
$ dotnet run
Min=1, Max=8, Avg=3.8
(1, 8, 3.8)
1..5
The element names exist only at compile time — ToString prints (1, 8, 3.8) without them. Deconstruction unpacks the tuple into variables, and _ skips the average.
Extension Methods
A static method whose first parameter is marked this can be called as if it were an instance method of that type.
string text = "hello world this is a long sentence";
Console.WriteLine(text.Truncate(15));
Console.WriteLine("hELLO wORLD".ToTitleCase());
string? missing = null;
Console.WriteLine(missing.IsBlank());
public static class StringExtensions
{
public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(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.InvariantCulture.TextInfo.ToTitleCase(s.ToLower());
}
$ dotnet run
hello world thi...
Hello World
True
missing.IsBlank() does not throw a NullReferenceException even though missing is null: an extension call is really StringExtensions.IsBlank(missing), a static call with null as the argument. ToTitleCase leaves all-caps words alone, so lower-casing first is what fixes hELLO.
Recursive Methods
Recursion needs a base case; memoization stops it recomputing the same subproblem.
using System.Diagnostics;
var sw = Stopwatch.StartNew();
Console.WriteLine($"{NaiveFib(35)} naive in {sw.ElapsedMilliseconds} ms");
sw.Restart();
Console.WriteLine($"{Fib(90)} memoized in {sw.ElapsedMilliseconds} ms");
static long NaiveFib(int n) => n <= 1 ? n : NaiveFib(n - 1) + NaiveFib(n - 2);
static long Fib(int n, Dictionary<int, long>? memo = null)
{
memo ??= new Dictionary<int, long>();
if (n <= 1) return n;
if (memo.TryGetValue(n, out long cached)) return cached;
return memo[n] = Fib(n - 1, memo) + Fib(n - 2, memo);
}
$ dotnet run
9227465 naive in 93 ms
2880067194370816120 memoized in 3 ms
The timings are from one run and will differ on your machine, but the shape will not. The naive version makes about 30 million calls for n = 35; the memoized one computes each value once, so n = 90 — far beyond what the naive version could finish — is effectively instant.
Practice
- Min and max in one pass. Write
(int Min, int Max) MinMax(params int[] values)that loops once, and call it withMinMax(4, -2, 9, 0).
Solution
var (lo, hi) = MinMax(4, -2, 9, 0);
Console.WriteLine($"{lo} {hi}");
(int Min, int Max) MinMax(params int[] values)
{
int min = int.MaxValue, max = int.MinValue;
foreach (int v in values)
{
if (v < min) min = v;
if (v > max) max = v;
}
return (min, max);
}
$ dotnet run
-2 9
- A TryParse of your own. Write
bool TryParsePoint(string s, out (int X, int Y) point)that accepts"3,4"and rejects"3;4".
Solution
foreach (var s in new[] { "3,4", "3;4" })
Console.WriteLine(TryParsePoint(s, out var pt) ? $"ok {pt}" : $"bad {s}");
bool TryParsePoint(string s, out (int X, int Y) point)
{
point = default;
var parts = s.Split(',');
if (parts.Length != 2) return false;
if (!int.TryParse(parts[0], out int x) || !int.TryParse(parts[1], out int y)) return false;
point = (x, y);
return true;
}
$ dotnet run
ok (3, 4)
bad 3;4
Assigning point = default first satisfies the “every path must assign” rule for out.
- Overload resolution. Add a
Print(long number)overload to thePrinterexample. Which lines change?
Solution
var p = new Printer();
p.Print(42);
p.Print(42L);
p.Print('x');
public class Printer
{
public void Print(int number) => Console.WriteLine($"int: {number}");
public void Print(long number) => Console.WriteLine($"long: {number}");
public void Print(double number) => Console.WriteLine($"double: {number}");
}
$ dotnet run
int: 42
long: 42
int: 120
Only 42L moves: an exact match beats a widening conversion. 'x' still prefers int, the closest widening.
- An extension on IEnumerable. Write
Every<T>(this IEnumerable<T> source, int n)that yields every n-th item, and use it onEnumerable.Range(1, 10).
Solution
Console.WriteLine(string.Join(", ", Enumerable.Range(1, 10).Every(3)));
public static class SeqExtensions
{
public static IEnumerable<T> Every<T>(this IEnumerable<T> source, int n)
{
int i = 0;
foreach (var item in source)
if (i++ % n == 0) yield return item;
}
}
$ dotnet run
1, 4, 7, 10
This is how LINQ itself is built: extension methods on IEnumerable<T>.