Defining and calling a method

A method is a named block of code that you can call from elsewhere, giving it input through parameters and getting a return value back. C# calls them methods rather than functions because, in principle, every one belongs to a type. The ideas behind them, decomposition and abstraction, are explained in functions and abstraction.

A method declaration starts with the return type, then the name (PascalCase by convention), then the parameters in parentheses, each with its own type. void means the method returns nothing.

decimal cost = EnergyCost(350m, 0.32m);
Console.WriteLine($"Energy cost: {cost:F2}");
PrintInvoiceLine("Standing charge", 7.5m);

static decimal EnergyCost(decimal kwh, decimal pricePerKwh)
{
    return kwh * pricePerKwh;
}

static void PrintInvoiceLine(string description, decimal amount)
{
    Console.WriteLine($"{description,-20}{amount,10:F2}");
}
Energy cost: 112.00
Standing charge           7.50

In a file with top-level statements, methods written at the end of the file, like these two, are technically local functions of the hidden Main method; that is why they can be declared after the code that calls them. In a larger program, methods live inside a class, with an access modifier such as public:

Console.WriteLine(Tariff.WithVat(100m));
Console.WriteLine(Tariff.WithVat(7.5m));

static class Tariff
{
    public const decimal VatRate = 0.21m;

    public static decimal WithVat(decimal amount)
    {
        return Math.Round(amount * (1 + VatRate), 2);
    }
}
121.00
9.08

static means the method belongs to the class itself, so you call it as Tariff.WithVat(…) without creating an object first. Methods without static work on one object's data; they are covered on classes and objects.

Return values

return ends the method and hands back a value of the declared return type. The compiler checks that every path through a non-void method ends in a return (or a throw):

Console.WriteLine(Grade(85));

static string Grade(int score)
{
    if (score >= 90)
    {
        return "A";
    }
    else if (score >= 50)
    {
        return "pass";
    }
}
example.cs(3,15): error CS0161: 'Grade(int)': not all code paths return a value

What should Grade(30) return? Python would silently return None; C# refuses to compile until you decide. To return more than one value, return a tuple and deconstruct it at the call site (more on tuples in records, structs and enums):

var (net, vat) = SplitVat(121.00m);
Console.WriteLine($"net {net:F2}, VAT {vat:F2}");

static (decimal Net, decimal Vat) SplitVat(decimal gross)
{
    decimal net = Math.Round(gross / 1.21m, 2);
    return (net, gross - net);
}
net 100.00, VAT 21.00

Method overloading

Method overloading means declaring several methods with the same name in one type, each with a different parameter list: a different number of parameters, different types, or both. At each call, the compiler picks the overload whose parameters fit the arguments best. You have used this all along: Console.WriteLine has overloads for string, int, decimal, bool and more.

Console.WriteLine(Format.Amount(1234.5m));
Console.WriteLine(Format.Amount(1234.5m, "EUR"));
Console.WriteLine(Format.Amount(98));

static class Format
{
    public static string Amount(decimal value) => value.ToString("F2");

    public static string Amount(decimal value, string currency) =>
        $"{currency} {value:F2}";

    public static string Amount(int cents) => Amount(cents / 100m);
}
1234.50
EUR 1234.50
0.98

The third overload reuses the first; that is a common way to avoid duplicate code. Two rules to remember:

  • The return type does not count. Two methods that differ only in what they return cannot be told apart at the call, so the compiler rejects the second one (error CS0111).
  • Local functions cannot be overloaded. Two top-level Describe methods in the same file give error CS0128 ("already defined in this scope"). Put overloads in a class, as above.

Optional parameters and named arguments

An optional parameter has a default value in the declaration, priority = "P3", and callers may leave it out. Optional parameters must come after all required ones, and the default must be a compile-time constant (a literal, a const, null or default): DateTime when = DateTime.Now is not allowed.

A named argument passes a value by parameter name instead of position: notify: false. That lets a caller skip some optional parameters and set a later one, and it makes calls with bare true, false or numbers readable.

CreateTicket("Portal login fails");
CreateTicket("Meter offline", "P2");
CreateTicket("Invoice amount wrong", notify: false);
CreateTicket(priority: "P1", summary: "Substation North down");

static void CreateTicket(string summary, string priority = "P3", bool notify = true)
{
    Console.WriteLine($"[{priority}] {summary} (notify customer: {notify})");
}
[P3] Portal login fails (notify customer: True)
[P2] Meter offline (notify customer: True)
[P3] Invoice amount wrong (notify customer: False)
[P1] Substation North down (notify customer: True)

Named arguments can come in any order, as the last call shows. Once you mix them with positional arguments, keep the positional ones in their correct places.

params: any number of arguments

A params parameter accepts any number of arguments of its element type, including none, and receives them as one collection. It must be the last parameter. You can also pass a ready-made array. This is how string.Join and Console.WriteLine(format, …) accept a variable number of values. It is the C# form of a variadic function.

Console.WriteLine(Total(12.50m, 7.25m, 3.00m));
Console.WriteLine(Total());

decimal[] lines = [40.00m, 2.10m];
Console.WriteLine(Total(lines));

static decimal Total(params decimal[] amounts)
{
    decimal sum = 0m;
    foreach (decimal amount in amounts)
    {
        sum += amount;
    }
    return sum;
}
22.75
0
42.10

Before C# 13, a params parameter had to be an array. Since C# 13 it can also be another collection type, such as params ReadOnlySpan<decimal> or params IEnumerable<decimal>, which can avoid creating an array for every call.

How arguments are passed

By default, C# passes arguments by value: the parameter is a new variable that starts as a copy of the argument (see pass by value). What gets copied depends on the kind of type, as described in variables, types and operators:

  • For a value type (int, decimal, bool, a struct) the data itself is copied. Changing the parameter never affects the caller.
  • For a reference type (a class, an array, a List<T>) the reference is copied. The parameter and the caller's variable point to the same object, so changes to that object are visible to both, but assigning a new object to the parameter is not.
int credit = 100;
AddBonus(credit);
Console.WriteLine($"credit: {credit}");

List<string> notes = ["meter replaced"];
AddNote(notes);
Console.WriteLine($"notes: {notes.Count}");
ReplaceNotes(notes);
Console.WriteLine($"notes: {notes.Count}");

static void AddBonus(int amount) => amount += 10;           // changes the copy only
static void AddNote(List<string> list) => list.Add("seal checked");  // same list
static void ReplaceNotes(List<string> list) => list = [];  // new list, copy only
credit: 100
notes: 2
notes: 2

ref parameters

A ref parameter does not receive a copy: it becomes another name for the caller's variable (see pass by reference). Every change, including assigning a new value, happens to the original. Both the declaration and the call say ref, so a reader of the call can see that the variable may change. The variable must be assigned before the call.

Two ways to pass the variable credit, which holds 100. Left, by value: the method's parameter amount is a separate box holding its own copy, 100. Right, with ref: the parameter amount has no box of its own; an arrow shows that it refers to the caller's credit box. by value: AddBonus(credit) by reference: AddBonus(ref credit) credit 100 amount 100 copied two variables credit 100 amount same variable one variable, two names
By value the method works on a copy; with ref the parameter is the caller's variable under another name.
int credit = 100;
AddBonus(ref credit);
Console.WriteLine($"credit: {credit}");

string first = "Amara", second = "Mei";
Swap(ref first, ref second);
Console.WriteLine($"{first}, {second}");

static void AddBonus(ref int amount) => amount += 10;

static void Swap(ref string a, ref string b)
{
    string temp = a;
    a = b;
    b = temp;
}
credit: 110
Mei, Amara

Use ref sparingly. A method that returns its result is easier to follow than one that changes its caller's variables; credit = WithBonus(credit) says the same thing more plainly. (For swapping, C# also has the tuple form (a, b) = (b, a).)

out parameters

An out parameter is a variable the method must assign before it returns; the caller does not need to give it a value first. Its classic use is a method that returns a bool ("did it work?") and hands the actual result back through out. The whole .NET library uses this pattern: int.TryParse, DateOnly.TryParse, Dictionary.TryGetValue. You can declare the variable right in the call (out int number or out var number), or throw the value away with out _.

bool ok = int.TryParse("42", out var reading);
Console.WriteLine($"{ok} {reading}");

ok = int.TryParse("4x2", out reading);
Console.WriteLine($"{ok} {reading}");

if (TryParseTicketNumber("INC-20481", out int number))
{
    Console.WriteLine($"Ticket number {number}");
}
if (!TryParseTicketNumber("REQ-0815", out _))
{
    Console.WriteLine("Not an incident ticket");
}

static bool TryParseTicketNumber(string id, out int number)
{
    number = 0;
    return id.StartsWith("INC-") && int.TryParse(id[4..], out number);
}
True 42
False 0
Ticket number 20481
Not an incident ticket

When parsing fails, TryParse sets the out variable to 0, which is why the second line prints 0. The compiler enforces the "must assign" rule:

Console.WriteLine(TryGetBalance("C-1001", out decimal balance));

static bool TryGetBalance(string customerId, out decimal balance)
{
    if (customerId == "C-1001")
    {
        balance = 12.50m;
        return true;
    }
    return false;
}
example.cs(10,5): error CS0177: The out parameter 'balance' must be assigned to before control leaves the current method

in parameters

An in parameter is passed by reference like ref, but the method may not change it. Its purpose is performance: a large struct is not copied for every call. For ordinary int, decimal or class arguments it gains nothing, and you will mostly meet it in performance-sensitive library code. The call may, but need not, say in.

Modifier Passed as Must be set before the call Method may change it
(none) a copy yes its own copy only
ref the variable itself yes yes
out the variable itself no yes, and must assign it
in the variable itself, read-only yes no

Local functions

A local function is a method declared inside another method. Only the surrounding method can call it, which keeps a small helper close to the one place it is used instead of cluttering the class. A local function can read and change the local variables of the method around it; it captures them, like a closure.

PrintMonthlyCosts([11.2m, 9.8m, 14.1m], 0.32m);

static void PrintMonthlyCosts(decimal[] dailyKwh, decimal pricePerKwh)
{
    decimal total = 0m;
    foreach (decimal kwh in dailyKwh)
    {
        total += Cost(kwh);
    }
    Console.WriteLine($"Total: {total:F2}");

    decimal Cost(decimal kwh) => Math.Round(kwh * pricePerKwh, 2);
}
Total: 11.23

Cost uses pricePerKwh from the outer method without receiving it as a parameter. When you do not want that, mark the local function static: it can then use only its own parameters, and the compiler stops accidental captures.

decimal pricePerKwh = 0.32m;
Console.WriteLine(Cost(10m));

static decimal Cost(decimal kwh) => kwh * pricePerKwh;
example.cs(4,43): error CS8421: A static local function cannot contain a reference to 'pricePerKwh'.

That is also why the earlier examples on this page declare their top-level methods static: as local functions of the hidden Main, they could otherwise read the top-level variables, which is rarely intended. A method may also call itself; see recursion.

Expression-bodied members

Many methods consist of a single return. An expression-bodied member replaces the braces and the return with => followed by the expression. It works for methods, local functions, read-only properties, constructors and more, and it is purely a shorter notation: the compiled code is the same. For a void method the expression is a statement such as a method call.

var meter = new Meter("MTR-0042", 412.5m);
Console.WriteLine(meter);
Console.WriteLine(meter.IsHigh);
Console.WriteLine(meter.CostAt(0.32m));

class Meter
{
    public Meter(string id, decimal kwh)
    {
        Id = id;
        Kwh = kwh;
    }

    public string Id { get; }
    public decimal Kwh { get; }

    public bool IsHigh => Kwh > 400m;                         // property
    public decimal CostAt(decimal price) => Math.Round(Kwh * price, 2);  // method
    public override string ToString() => $"{Id}: {Kwh} kWh";
}
MTR-0042: 412.5 kWh
True
132.00

Use => when the body really is one short expression. As soon as you need a temporary variable or an if, write a normal block body. Note that => also appears in lambda expressions and in switch expressions; the arrow always means "produces".