Intermediate

Functions in C#

Learn how to define and call functions in C# - parameters, default and named arguments, scope, recursion, and higher-order functions with lambdas and LINQ, all runnable with Docker

Functions are how you wrap a piece of logic in a name so you can call it again and again instead of repeating yourself. In C# the line between a “function” and a “method” is mostly about where it lives: a function declared inside a class is a method, while modern top-level programs let you write local functions that read like standalone functions. Both share the same shape — a return type, a name, a parameter list, and a body.

As a multi-paradigm language, C# treats functions from several angles at once. On the object-oriented side you have instance and static methods bound to types. On the functional side, functions are values: the Func<> and Action<> delegate types let you store a function in a variable, pass it as an argument, and return it from another function. This is what powers lambda expressions and the LINQ query methods that are everywhere in idiomatic C#.

Because C# is statically and strongly typed, every parameter and return value has a type the compiler checks before your program runs. That means a mismatched argument, a forgotten return, or the wrong return type is caught at compile time rather than blowing up at runtime. In this tutorial you’ll define functions, give parameters default and named values, explore local versus shared scope, write recursive functions, and pass functions around as first-class values — each as a complete, runnable program.

Defining and Calling Functions

A function declares its return type, a name, a parenthesized parameter list, and a body in braces. Use return to hand a value back to the caller; a function that returns nothing has the return type void. For short one-liners, the => expression-bodied syntax drops the braces and the return keyword entirely.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// A function with two typed parameters and an int return type
int Add(int a, int b)
{
    return a + b;
}

// Expression-bodied function: concise syntax for a single expression
int Square(int n) => n * n;

// A 'void' function returns nothing - it just performs an action
void PrintBanner(string text)
{
    Console.WriteLine($"=== {text} ===");
}

PrintBanner("Functions");
Console.WriteLine($"Add(3, 4) = {Add(3, 4)}");
Console.WriteLine($"Square(5) = {Square(5)}");

These are local functions — declared directly inside the top-level program. C# lets you call them before they appear in the file, because the compiler reads all declarations in a scope before running any statements.

Run it:

1
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 sh -c "cp Program.cs Program.cs.bak && dotnet new console -o . --force >/dev/null 2>&1 && cp Program.cs.bak Program.cs && rm Program.cs.bak && dotnet run"

Expected output:

=== Functions ===
Add(3, 4) = 7
Square(5) = 25

Default and Named Arguments

A parameter can carry a default value, letting callers omit it when the default is fine. Named arguments let you pass a value by writing parameterName: value, which both documents the call and lets you skip over earlier optional parameters without supplying placeholders.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 'exponent' defaults to 2 when the caller leaves it out
int Power(int baseValue, int exponent = 2)
{
    int result = 1;
    for (int i = 0; i < exponent; i++)
    {
        result *= baseValue;
    }
    return result;
}

// Several parameters, two of them optional
string FormatPrice(decimal amount, string currency = "USD", int decimals = 2)
{
    return $"{currency} {Math.Round(amount, decimals)}";
}

Console.WriteLine(Power(5));                       // uses the default exponent of 2
Console.WriteLine(Power(2, 10));                   // explicit exponent
Console.WriteLine(FormatPrice(19.95m));            // all defaults
Console.WriteLine(FormatPrice(8.5m, "EUR"));       // override currency positionally
Console.WriteLine(FormatPrice(100m, decimals: 0)); // named argument keeps the default currency

The last call is where named arguments earn their keep: FormatPrice(100m, decimals: 0) overrides only decimals while leaving currency at its default "USD", with no need to repeat the currency in the call.

Run it with the same Docker command shown above. Expected output:

25
1024
USD 19.95
EUR 8.5
USD 100

Variable Scope

A variable declared inside a function is local: it exists only while that call runs and is invisible to the rest of the program. Each call gets its own fresh copies. When you need a value that several functions share, put it in a class as a const or static member rather than reaching for mutable global state.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 'subtotal' and 'tax' are local - they exist only during this call
double CalculateTotal(double price, int quantity)
{
    double subtotal = price * quantity;
    double tax = subtotal * TaxRates.Standard;
    return subtotal + tax;
}

Console.WriteLine(CalculateTotal(10.0, 3));
Console.WriteLine($"Tax rate is {TaxRates.Standard}");

// A class holds constants and static members shared across the program.
// In a top-level program, type declarations come after the statements.
class TaxRates
{
    public const double Standard = 0.08;
}

If you tried to read subtotal outside CalculateTotal, the program would fail to compile — the name simply does not exist in that scope. That is the static type system protecting you from a common class of bugs.

Run it with the same Docker command. Expected output:

32.4
Tax rate is 0.08

Recursion

A recursive function calls itself, breaking a problem into a smaller version of the same problem until it hits a base case that stops the recursion. C# handles recursion naturally, and the type checker verifies that the parameter and return types line up at every level of the call chain.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// factorial(n) = n * (n-1) * ... * 1
long Factorial(int n)
{
    if (n <= 1) return 1;            // base case stops the recursion
    return n * Factorial(n - 1);     // recursive case
}

// Each Fibonacci number is the sum of the previous two
int Fibonacci(int n)
{
    if (n < 2) return n;             // base cases: fib(0)=0, fib(1)=1
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Console.WriteLine($"Factorial(5) = {Factorial(5)}");
Console.WriteLine($"Fibonacci(10) = {Fibonacci(10)}");

Factorial returns long because factorials grow quickly and would overflow a 32-bit int for larger inputs. Every recursive function needs a base case — here n <= 1 and n < 2 — otherwise it would call itself forever.

Run it with the same Docker command. Expected output:

Factorial(5) = 120
Fibonacci(10) = 55

Higher-Order Functions, Lambdas, and LINQ

This is where C#’s functional side shows through. Functions are first-class values: the Func<...> delegate type represents a function that returns a value, and Action<...> represents one that returns nothing. A lambda (x => x * 2) creates such a function inline. A higher-order function is simply one that takes or returns another function — and the entire LINQ library is built on exactly this idea.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// A higher-order function: it accepts another function as a parameter
int ApplyTwice(Func<int, int> f, int value) => f(f(value));

// Func<int, int> is "a function from int to int"; lambdas create them inline
Func<int, int> doubler = x => x * 2;
Func<int, int, int> multiply = (x, y) => x * y;

Console.WriteLine($"doubler(8) = {doubler(8)}");
Console.WriteLine($"multiply(6, 7) = {multiply(6, 7)}");
Console.WriteLine($"ApplyTwice(doubler, 5) = {ApplyTwice(doubler, 5)}");

// Action<T> represents a function that returns nothing
Action<string> shout = msg => Console.WriteLine(msg.ToUpper());
shout("hello");

// LINQ methods are higher-order functions: each takes a lambda
int[] numbers = { 1, 2, 3, 4, 5 };
var evens = numbers.Where(n => n % 2 == 0);
var squares = numbers.Select(n => n * n);

Console.WriteLine($"Evens: {string.Join(", ", evens)}");
Console.WriteLine($"Squares: {string.Join(", ", squares)}");

ApplyTwice(doubler, 5) calls doubler on 5 to get 10, then on 10 to get 20. The Where and Select calls hand a lambda to LINQ, which applies it to each element — letting you parameterize behavior, not just data.

Run it with the same Docker command. Expected output:

doubler(8) = 16
multiply(6, 7) = 42
ApplyTwice(doubler, 5) = 20
HELLO
Evens: 2, 4
Squares: 1, 4, 9, 16, 25

Running with Docker

Each example in this tutorial is a complete program. Save it as Program.cs and run it with the official .NET SDK image — no local install required.

1
2
3
4
5
# Pull the official .NET SDK image (one-time)
docker pull mcr.microsoft.com/dotnet/sdk:9.0

# Compile and run Program.cs
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 sh -c "cp Program.cs Program.cs.bak && dotnet new console -o . --force >/dev/null 2>&1 && cp Program.cs.bak Program.cs && rm Program.cs.bak && dotnet run"

The command preserves your Program.cs, scaffolds a temporary project around it, compiles, and runs it.

Key Concepts

  • Functions vs methods — a function declared inside a class is a method; modern top-level programs let you write local functions that read like standalone functions. Both share the same return-type/name/parameters/body shape.
  • Return types are explicit and checked — every function declares what it returns (or void for nothing), and the strong, static type system catches mismatched arguments and missing returns at compile time.
  • Expression-bodied syntax — the => form (int Square(int n) => n * n;) is a concise alternative to a full braced body for single-expression functions.
  • Default and named arguments — parameters can carry default values, and named arguments (decimals: 0) let callers override just the arguments they care about while keeping the other defaults.
  • Local scope by default — variables inside a function exist only for that call; share values through const or static members on a class rather than mutable globals.
  • Recursion needs a base case — a function that calls itself must have a stopping condition, or it will recurse indefinitely; choose a return type wide enough (like long) to avoid overflow.
  • Functions are first-class valuesFunc<> and Action<> delegates plus lambdas let you store functions in variables and pass them to higher-order functions, the foundation of LINQ’s Where, Select, and friends.

Running Today

All examples can be run using Docker:

docker pull mcr.microsoft.com/dotnet/sdk:9.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining