Free Handbook · Every example compiled & verified

Methods

Write and call C# methods: parameters, optional and named arguments, params, overloading, ref/out/in, recursion, tuples and local functions.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 04 · what you'll be able to do

  • Declare methods with parameters and return types, as local functions in Program.cs or as static methods on a class
  • Call methods with optional and named arguments, params lists and overloads, and read the compiler error when a call matches nothing
  • Predict what a method can and cannot change in its caller: value types, references, and ref/out/in
  • Return several values with a tuple and unpack them with deconstruction
  • Trace a recursive method call by call, and know when a local function is the cleaner choice
01

Defining and calling a method

A method is a named block of code that takes some inputs (parameters), does one job and optionally hands back a result (its return value). The first line of a method, its signature, says all of that: static int Square(int n) reads "a method called Square that takes an int called n and returns an int". A method that returns nothing is declared void.

In a file-based C# program you can write a method in two places. Among the top-level statements, it is a local function: it belongs to the hidden Main and can be called from anywhere in the top-level code, even above where it is written. Inside a class, it is a normal method; mark it static when it does not need an object, and call it as ClassName.Method(). Real projects put almost every method on a type; small scripts and this handbook use both.

C#Program.cs
Greet("Ana");
Console.WriteLine(Square(7));
Console.WriteLine(MathUtil.Cube(3));
Console.WriteLine(MathUtil.IsEven(10));

// local functions: callable anywhere in the top-level code
static void Greet(string name)
{
    Console.WriteLine($"Hello, {name}!");
}

static int Square(int n)
{
    return n * n;
}

// a static method on a class: called as MathUtil.Cube(...)
static class MathUtil
{
    public static int Cube(int n) => n * n * n;

    public static bool IsEven(int n) => n % 2 == 0;
}
Outputcompiled & run with real C#
Hello, Ana!
49
27
True
Your turn

Add a method static double Average(int a, int b) to MathUtil and print MathUtil.Average(3, 4). Why does (a + b) / 2 print 3 instead of 3.5?

The return statement ends the method immediately and hands the value back. A method declared to return int must return an int on every path through it; the compiler checks this, and a missing return after an if is one of the first errors every beginner meets.

Error you will hit

CS0161: not all code paths return a value

C#
Console.WriteLine(Sign(-4));

static string Sign(int n)
{
    if (n > 0) return "positive";
    if (n < 0) return "negative";
}
Program.cs(3,15): error CS0161: 'Sign(int)': not all code paths return a value
Why the compiler said that

When n is 0, neither if matches and execution falls off the end of the method with no string to hand back. C# does not invent a default value, so it refuses to compile. The compiler does not try to prove that one of your conditions is always true; it only follows the paths.

The fix

Make the last path return something (or throw). A final return with no condition, or a switch expression with a _ arm, covers every case.

C#
Console.WriteLine(Sign(-4));

static string Sign(int n)
{
    if (n > 0) return "positive";
    if (n < 0) return "negative";
    return "zero";
}
Naming
C# method names are PascalCase verbs or verb phrases: CalculateTotal, IsValid, TryParse. Parameters and locals are camelCase. A method whose name starts with Is, Has or Can should return bool.
02

Parameters, optional and named arguments

A parameter is the variable in the method declaration; an argument is the value you pass at the call. By default arguments are matched to parameters by position. Two features make calls more readable: an optional parameter has a default value (int tries = 3) so the caller can leave it out, and a named argument (tries: 5) says which parameter a value is for, so the call reads like a sentence and you can skip optional ones in the middle.

C#Program.cs
Console.WriteLine(FormatPrice(1234.56));
Console.WriteLine(FormatPrice(1234.56, "EUR"));
Console.WriteLine(FormatPrice(1234.56, decimals: 0));
Console.WriteLine(FormatPrice(currency: "GBP", amount: 9.99));

static string FormatPrice(double amount, string currency = "USD", int decimals = 2)
{
    string number = amount.ToString("N" + decimals, System.Globalization.CultureInfo.InvariantCulture);
    return $"{number} {currency}";
}
Outputcompiled & run with real C#
1,234.56 USD
1,234.56 EUR
1,235 USD
9.99 GBP
Your turn

Add a bool symbolFirst = false parameter that prints USD 1,234.56 when true, and call it with only a named symbolFirst: true.

  • Optional parameters must come after all required ones.
  • A default value must be a compile-time constant (3, "USD", null, default), not DateTime.Now or new List<int>().
  • Once you pass a named argument out of position, every argument after it must be named too.
  • Named arguments pay off most for bool parameters: Save(overwrite: true) says what Save(true) hides.
Error you will hit

CS7036: a required argument is missing

C#
Console.WriteLine(Area(5));

static double Area(double width, double height) => width * height;
Program.cs(1,19): error CS7036: There is no argument given that corresponds to the required parameter 'height' of 'Area(double, double)'
Why the compiler said that

Both parameters of Area are required, and the call passes only one. C# never guesses a missing argument: a parameter can be skipped only if the method gave it a default value.

The fix

Pass every required argument, or give the parameter a default if a sensible one exists.

C#
Console.WriteLine(Area(5, 2));
Console.WriteLine(Area(5));

static double Area(double width, double height = 1) => width * height;
03

params and method overloading

params lets a method take any number of arguments of one type. The caller writes Sum(1, 2, 3) and the method receives them as a single collection; the caller can also pass an existing array. It must be the last parameter. Since C# 13 the params parameter may be a ReadOnlySpan<T> or a List<T> as well as an array; params T[] is still what you will read in most code.

C#Program.cs
Console.WriteLine(Sum());
Console.WriteLine(Sum(4));
Console.WriteLine(Sum(1, 2, 3, 4));

int[] saved = { 10, 20 };
Console.WriteLine(Sum(saved));

Console.WriteLine(Join("-", "a", "b", "c"));

static int Sum(params int[] numbers)
{
    int total = 0;
    foreach (int n in numbers) total += n;
    return total;
}

static string Join(string separator, params string[] parts) =>
    string.Join(separator, parts);
Outputcompiled & run with real C#
0
4
10
30
a-b-c

Overloading means several methods share one name but have different parameter lists: a different number of parameters, or different types. The compiler picks the best match at compile time from the arguments. The return type is not part of the choice, so two methods that differ only in what they return cannot coexist. Console.WriteLine itself has 18 overloads, which is why it accepts an int, a string or a bool.

C#Program.cs
Console.WriteLine(Describe.Of(42));
Console.WriteLine(Describe.Of(4.2));
Console.WriteLine(Describe.Of("42"));
Console.WriteLine(Describe.Of(4, 2));
Console.WriteLine(Describe.Of('x'));   // char widens to int

static class Describe
{
    public static string Of(int n) => $"int {n}";
    public static string Of(double d) => $"double {d}";
    public static string Of(string s) => $"string \"{s}\"";
    public static string Of(int a, int b) => $"two ints {a},{b}";
}
Outputcompiled & run with real C#
int 42
double 4.2
string "42"
two ints 4,2
int 120
Your turn

Add an Of(long n) overload. Which one does Describe.Of(42) call now, and which does Describe.Of(42L) call?

Local functions cannot be overloaded
Overloads must live on a type. Two top-level local functions with the same name, even with different parameters, fail with CS0128 ("A local variable or function named ... is already defined"). When you need overloads in a script, put them in a static class as above.
Error you will hit

CS0121: an ambiguous call between two overloads

C#
Console.WriteLine(Pick.Max(1, 2));

static class Pick
{
    public static double Max(int a, double b) => Math.Max(a, b);
    public static double Max(double a, int b) => Math.Max(a, b);
}
Program.cs(1,24): error CS0121: The call is ambiguous between the following methods or properties: 'Pick.Max(int, double)' and 'Pick.Max(double, int)'
Why the compiler said that

Both overloads accept (1, 2): each needs exactly one int-to-double conversion, so neither is a better match than the other. When the compiler cannot rank the candidates it refuses to choose rather than pick one silently.

The fix

Remove the overload pair that invites ambiguity (one Max(double, double) covers both), or make the call exact with a cast such as Pick.Max(1, 2.0).

C#
Console.WriteLine(Pick.Max(1, 2));

static class Pick
{
    public static double Max(double a, double b) => Math.Max(a, b);
}
04

What a method can change: value and reference types

By default C# passes every argument by value: the parameter is a new variable holding a copy of what the caller passed. What that copy is depends on the type. For a value type (int, double, bool, any struct) the copy is the data itself, so the method cannot touch the caller's variable. For a reference type (arrays, List<T>, string, any class) the copy is a reference to the same object on the heap, so the method can change the object, but pointing its own parameter at a new object does nothing to the caller.

C#Program.cs
int score = 10;
AddBonus(score);
Console.WriteLine($"score after AddBonus: {score}");

int[] scores = { 10, 20 };
AddBonusToAll(scores);
Console.WriteLine($"scores after AddBonusToAll: {string.Join(", ", scores)}");

Replace(scores);
Console.WriteLine($"scores after Replace: {string.Join(", ", scores)}");

static void AddBonus(int s)
{
    s += 5;                      // changes the local copy only
}

static void AddBonusToAll(int[] arr)
{
    for (int i = 0; i < arr.Length; i++) arr[i] += 5;   // same array object
}

static void Replace(int[] arr)
{
    arr = new[] { 0, 0 };        // re-points the local copy of the reference
    arr[0] = 99;
}
Outputcompiled & run with real C#
score after AddBonus: 10
scores after AddBonusToAll: 15, 25
scores after Replace: 15, 25
Your turn

Change Replace to static void Replace(ref int[] arr) and call it with Replace(ref scores). What prints now, and why?

Strings look like value types but are not
string is a reference type, but it is immutable: every method that seems to change a string (ToUpper, +, Replace) returns a new string. So a method can never change the caller's string through a parameter; it has to return the new one.

The rule in one line: a method can mutate an object you pass it, but cannot reassign your variable, unless the parameter is declared ref or out. The next lesson covers those.

05

ref, out and in

Three modifiers pass an argument by reference: the parameter becomes another name for the caller's variable, not a copy of it. The modifier is written at both the declaration and the call, so a reader of the call can see that the variable may change.

ModifierMust be assigned before the call?Method may change it?Typical use
refYesYesSwap two variables; update a caller's counter
outNoMust assign it before returningA second result, as in int.TryParse(s, out int n)
inYesNo (read-only)Pass a large struct without copying it
VisualizeSwapping two ints with refStep 1 / 6
int a = 1, b = 2;
Swap(ref a, ref b);
Console.WriteLine($"a={a} b={b}");
static void Swap(ref int x, ref int y)
{
int tmp = x;
x = y;
y = tmp;
}
Line 1

Two ints in the caller.

Variables now
a1
b2
All 6 steps as a table
StepLineWhat happenedVariables now
11Two ints in the caller.a = 1 b = 2
22The call passes ref a and ref b: x becomes another name for a, y for b. No copies are made.a (x) = 1 b (y) = 2
37tmp is an ordinary local: it gets a copy of the value behind x.tmp = 1
48Writing to x writes straight into the caller's a.a (x) = 2
59Writing to y writes into b.b (y) = 1
63Back in the caller, the variables themselves were swapped.
C#Program.cs
if (TryDivide(10, 4, out double result))
    Console.WriteLine($"10 / 4 = {result}");

if (!TryDivide(1, 0, out _))          // _ discards an out value you do not need
    Console.WriteLine("cannot divide by zero");

int visits = 0;
Count(ref visits);
Count(ref visits);
Console.WriteLine($"visits = {visits}");

var p = new Point3(1, 2, 3);
Console.WriteLine(Length(in p));

static bool TryDivide(double a, double b, out double quotient)
{
    if (b == 0) { quotient = 0; return false; }
    quotient = a / b;
    return true;
}

static void Count(ref int counter) => counter++;

static double Length(in Point3 v) => Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);

readonly record struct Point3(double X, double Y, double Z);
Outputcompiled & run with real C#
10 / 4 = 2.5
cannot divide by zero
visits = 2
3.7416573867739413

out double result declares the variable right in the call. This is the Try pattern that int.TryParse and Dictionary.TryGetValue use.

Error you will hit

CS0177: an out parameter left unassigned

C#
Console.WriteLine(TryFirstWord("hello world", out string w) ? w : "none");

static bool TryFirstWord(string text, out string word)
{
    if (text.Length == 0) return false;
    word = text.Split(' ')[0];
    return true;
}
Program.cs(5,27): error CS0177: The out parameter 'word' must be assigned to before control leaves the current method
Why the compiler said that

An out parameter is a promise: whatever path the method takes, the caller's variable will hold a value when it returns. The early return false breaks that promise because word was never assigned on that path.

The fix

Assign the out parameter on every path, usually to a harmless default before the early return.

C#
Console.WriteLine(TryFirstWord("hello world", out string w) ? w : "none");

static bool TryFirstWord(string text, out string word)
{
    word = "";
    if (text.Length == 0) return false;
    word = text.Split(' ')[0];
    return true;
}
Error you will hit

CS1620: forgetting ref at the call site

C#
int n = 5;
Double(n);
Console.WriteLine(n);

static void Double(ref int x) => x *= 2;
Program.cs(2,8): error CS1620: Argument 1 must be passed with the 'ref' keyword
Why the compiler said that

The method is declared with ref int x, so a plain n does not match it. C# makes you write ref at the call on purpose: anyone reading Double(ref n) knows n may change.

The fix

Add ref at the call site. Better still, if the method only produces a new value, return it (n = Doubled(n)) and drop ref altogether.

C#
int n = 5;
Double(ref n);
Console.WriteLine(n);

static void Double(ref int x) => x *= 2;
In real jobs
Outside the Try pattern, ref and out are rare in application code: returning a value or a tuple is clearer. You will see ref, in and ref readonly in performance-sensitive libraries (game engines, parsers, Span<T> code) where avoiding copies of big structs matters.
06

Expression-bodied methods and recursion

When a method is a single expression, the => form drops the braces and the return: static int Square(int n) => n * n; means exactly the same as the block version. It works for methods, properties, constructors and operators. Use it when the whole body fits comfortably on one line; switch back to braces the moment you need a second statement.

A recursive method calls itself on a smaller version of the problem. Every recursive method needs a base case that answers directly without recursing, and every recursive call must move towards it. Each call gets its own frame on the call stack with its own parameters, which is what the trace below shows.

C#Program.cs
Console.WriteLine(Power(2, 10));
Console.WriteLine(SumDigits(9045));
Console.WriteLine(Countdown(3));

static long Power(long b, int exp) => exp == 0 ? 1 : b * Power(b, exp - 1);

static int SumDigits(int n) => n < 10 ? n : n % 10 + SumDigits(n / 10);

static string Countdown(int n) => n == 0 ? "liftoff" : n + " " + Countdown(n - 1);
Outputcompiled & run with real C#
1024
18
3 2 1 liftoff
VisualizeSumDigits(345), one call at a timeStep 1 / 7
Console.WriteLine(SumDigits(345));
static int SumDigits(int n)
{
if (n < 10) return n;
return n % 10 + SumDigits(n / 10);
}
Line 1

The first call starts with n = 345.

Variables now
n345
All 7 steps as a table
StepLineWhat happenedVariables now
11The first call starts with n = 345.n = 345
26345 is not below 10, so it needs 5 + SumDigits(34). This frame waits.n = 345 waiting = 5 + ?
36A new frame with its own n = 34: it needs 4 + SumDigits(3) and waits too.n = 34 waiting = 5 + (4 + ?)
45Third frame: n = 3 is the base case and returns 3 without recursing.n = 3 waiting = 5 + (4 + 3)
56The n = 34 frame resumes: 4 + 3 = 7, and returns 7.n = 34 waiting = 5 + 7
66The first frame resumes: 5 + 7 = 12.n = 345 waiting = 12
71The result reaches WriteLine.
Stack overflow ends the process
Forget the base case and every call adds a frame until the stack runs out (about 1 MB by default). In .NET a StackOverflowException cannot be caught: the process prints "Stack overflow." and is killed. Recursion depth of a few thousand is fine; for anything that can go deeper (walking a long linked list, a huge directory tree), use a loop with an explicit Stack<T>. Module 13 revisits this.
07

Returning several values: tuples and deconstruction

A method has one return type, but that type can be a tuple: (int Min, int Max) is a lightweight value holding two named ints. The caller can keep the tuple and read result.Min, or deconstruct it straight into separate variables with var (lo, hi) = MinMax(data);. Use a discard _ for a part you do not need.

C#Program.cs
int[] temps = { 18, 25, 11, 30, 22 };

var stats = MinMax(temps);
Console.WriteLine($"min {stats.Min}, max {stats.Max}");

var (lo, hi) = MinMax(temps);          // deconstruction
Console.WriteLine($"range {hi - lo}");

var (_, top) = MinMax(temps);          // discard the min
Console.WriteLine($"top {top}");

var (q, r) = DivRem(17, 5);
Console.WriteLine($"17 = 5 * {q} + {r}");

(lo, hi) = (hi, lo);                    // swap without a temp variable
Console.WriteLine($"lo={lo} hi={hi}");

static (int Min, int Max) MinMax(int[] values)
{
    int min = values[0], max = values[0];
    foreach (int v in values)
    {
        if (v < min) min = v;
        if (v > max) max = v;
    }
    return (min, max);
}

static (int Quotient, int Remainder) DivRem(int a, int b) => (a / b, a % b);
Outputcompiled & run with real C#
min 11, max 30
range 19
top 30
17 = 5 * 3 + 2
lo=30 hi=11
Your turn

Write static (string First, string Last) SplitName(string full) and deconstruct "Grace Hopper" into two variables.

Return a tuple when

  • The values belong together only for this one call
  • It is a private or local helper
  • There are two or three values

Return a record or class when

  • The shape appears in several places or in a public API
  • It needs methods, validation or documentation
  • There are more than three values
Tuples are value types
A tuple like (int, int) is a System.ValueTuple struct: copying it copies both values, and two tuples with equal elements compare equal with ==. The element names (Min, Max) exist only at compile time; at runtime they are Item1 and Item2.
08

Local functions and static local functions

A local function is a method declared inside another method. It is visible only there, which keeps a helper next to the one place that uses it instead of cluttering the class. A local function can read and change the enclosing method's variables (it captures them). Mark it static to forbid capturing: the compiler then guarantees it depends only on its parameters, which makes it easier to read and cheaper to call.

C#Program.cs
Console.WriteLine(Report.Build(new[] { 3, -1, 8, 0, -5 }));

static class Report
{
    public static string Build(int[] values)
    {
        int skipped = 0;
        var kept = new List<string>();

        foreach (int v in values)
        {
            if (IsUsable(v)) kept.Add(Label(v));
            else Skip();
        }
        return $"{string.Join(" ", kept)} (skipped {skipped})";

        // captures 'skipped' from Build
        void Skip() => skipped++;

        // static: uses only its parameter
        static bool IsUsable(int n) => n > 0;
        static string Label(int n) => $"[{n}]";
    }
}
Outputcompiled & run with real C#
[3] [8] (skipped 3)
Your turn

Add a local function int Total() that sums values (it may capture it) and append the total to the report.

Error you will hit

CS8421: a static local function capturing a variable

C#
int limit = 10;
Console.WriteLine(IsSmall(4));

static bool IsSmall(int n) => n < limit;
Program.cs(4,35): error CS8421: A static local function cannot contain a reference to 'limit'.
Why the compiler said that

static on a local function is a promise that it uses nothing from the enclosing scope. Reading limit breaks that promise, and the compiler says so instead of silently capturing it.

The fix

Pass the value in as a parameter (keeps the function static and honest), or remove static if capturing is really what you want.

C#
int limit = 10;
Console.WriteLine(IsSmall(4, limit));

static bool IsSmall(int n, int max) => n < max;
Local function or lambda?
Both can capture. Prefer a local function when the helper has a name and is called in more than one place, needs recursion, or uses ref/out/params. Use a lambda when you are handing a small function to something else, such as a LINQ Where (see Module 09).
Signature
A method's name plus its parameter types (and ref/out/in modifiers). Overloads must differ in signature; the return type does not count.
Parameter / argument
The parameter is the variable in the declaration; the argument is the value passed at the call.
Optional parameter
A parameter with a compile-time constant default, so callers may leave it out.
Named argument
An argument written as name: value, matched by name instead of position.
params
Marks the last parameter as taking any number of arguments, received as one array or collection.
Overloading
Several methods on one type with the same name and different parameter lists; the compiler picks one at compile time.
ref / out / in
Pass the caller's variable itself: ref = read and write, out = must be assigned by the method, in = read-only.
Expression-bodied member
A member whose body is a single expression after =>.
Base case
The input a recursive method answers directly, without calling itself; it is what stops the recursion.
Deconstruction
Unpacking a tuple or record into separate variables: var (a, b) = pair;.
Local function
A method declared inside another method; static local functions cannot capture outer variables.
C#
var list = new List<int> { 1 };
Change(list);
Console.WriteLine(list.Count);

static void Change(List<int> l)
{
    l.Add(2);
    l = new List<int>();
    l.Add(3);
}
Quick check

What does the program above print?

Quick check

Which pair of declarations can live on the same class?

Frequently asked questions

What is the difference between a method and a function in C#?
In C# every function belongs to a type, so they are called methods. A local function is a method declared inside another method; in a file with top-level statements, methods you write outside any class are local functions of the generated Main.
When should I use ref or out in C#?
Use out for the Try pattern (bool TryX(input, out result)) and ref when a method genuinely must change the caller's variable, such as a swap. For everything else, return a value or a tuple; it is easier to read and test.
How do I return multiple values from a C# method?
Return a tuple such as (int Min, int Max) and deconstruct it at the call with var (min, max) = MinMax(data). For a shape used in many places, return a record instead.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.