Methods in C#: Parameters, Overloading, ref/out/in and params
The basic tools for writing methods in C#: parameters passed by value and return values, overloading and signatures, optional and named arguments, ref, out and in, params, local functions and why they cannot be overloaded, and expression-bodied members.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We wrote a small method that applies a 10 percent discount to a book's price and passed it a price of 100. What is the price variable after the method?
decimal price = 100m;
ApplyDiscount(price);
Console.WriteLine(price);
static void ApplyDiscount(decimal amount)
{
amount = amount * 0.9m;
}
100
The answer is 100. When ApplyDiscount was called, the value of price was copied into the amount parameter. The method set its own copy to 90, but that copy disappeared when the method ended; the price on the calling side was never touched. In C#, parameters are passed by value by default. The natural way for a method to hand out its result is not to change its parameter but to return a value. In this post we look at all the basic tools for writing methods, and at the end we get to the ways of changing this behavior on purpose: ref and out.
Anatomy of a Method
In a real project, methods live inside a class. We will look at classes in detail in the Object-Oriented Programming with C# series; for now think of a static class as a box that groups related methods together:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
decimal price = 100m;
decimal discounted = Pricing.ApplyDiscount(price, 0.10m);
Console.WriteLine($"{price} -> {discounted}");
Pricing.PrintLabel("Dune", discounted);
static class Pricing
{
public static decimal ApplyDiscount(decimal price, decimal rate)
{
if (rate < 0m || rate > 1m)
{
throw new ArgumentOutOfRangeException(nameof(rate));
}
return price * (1m - rate);
}
public static void PrintLabel(string title, decimal price)
{
Console.WriteLine($"{title}: {price:0.00}");
}
}
100 -> 90.00
Dune: 90.00
Let's read the declaration of ApplyDiscount from left to right. public is the access modifier: it says we can call this method from outside the class. static says the method belongs to the class itself, not to an instance; that is why we call it as Pricing.ApplyDiscount(...). Then come the return type (decimal), the method's name, and the parameters with their types in parentheses. The return in the body returns the result and ends the method right there.
PrintLabel does not return anything, it only writes to the screen; that is why its return type is void. Method names in C# are written in PascalCase and usually start with a verb: ApplyDiscount, PrintLabel, CalculateTotal. Parameters are camelCase.
The methods we wrote below the top-level code in the earlier posts, like static decimal DiscountRate(...), were actually local functions; we will see the difference in a moment.
Overloading
You can write more than one method with the same name but different parameters. This is called overloading:
Console.WriteLine(Pricing.Total(10m, 3));
Console.WriteLine(Pricing.Total(new[] { 10m, 20m }));
static class Pricing
{
public static decimal Total(decimal unitPrice, int quantity) => unitPrice * quantity;
public static decimal Total(decimal[] prices)
{
decimal sum = 0m;
foreach (decimal price in prices)
{
sum += price;
}
return sum;
}
}
30
30
The compiler decides which method to call by looking at the number and types of the arguments. A method's name and its parameter types together make up its signature. The return type is not part of the signature; you cannot write two methods that differ only in their return type:
Console.WriteLine(Pricing.Total(10m, 3));
static class Pricing
{
public static decimal Total(decimal unitPrice, int quantity) => unitPrice * quantity;
public static int Total(decimal unitPrice, int quantity) => (int)(unitPrice * quantity);
}
Program.cs(7,23): error CS0111: Type 'Pricing' already defines a member called 'Total' with the same parameter types
Building overloads on very similar types can also give you a headache. Because int converts implicitly to both decimal and double, the compiler cannot pick one in the call below:
Console.WriteLine(Pricing.Round(10));
static class Pricing
{
public static decimal Round(decimal value) => Math.Round(value, 2);
public static decimal Round(double value) => (decimal)Math.Round(value, 2);
}
Program.cs(1,27): error CS0121: The call is ambiguous between the following methods or properties: 'Pricing.Round(decimal)' and 'Pricing.Round(double)'
In an ambiguity like this, the caller has to state the choice explicitly by writing 10m or 10.0. A good set of overloads never makes the caller think about which method a call goes to.
Optional and Named Arguments
If you give a parameter a default value, that parameter becomes optional and the caller can leave it out:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Shipping.Fee(2.0m));
Console.WriteLine(Shipping.Fee(2.0m, express: true));
Console.WriteLine(Shipping.Fee(weightKg: 2.0m, country: "DE"));
Console.WriteLine(Shipping.Fee(2.0m, "DE", true));
static class Shipping
{
public static decimal Fee(decimal weightKg, string country = "TR", bool express = false)
{
decimal fee = country == "TR" ? 30m : 120m;
fee += weightKg * 5m;
return express ? fee * 2m : fee;
}
}
40.0
80.0
130.0
260.0
On the second line, express: true is a named argument. We skipped the country parameter in between and gave a value straight to express. The real benefit of named arguments is readability: in the Fee(2.0m, "DE", true) call on the last line you cannot tell what true means without looking at the method, while express: true explains itself. If you pass a bool to a method, using a named argument on the calling side is a good habit.
There are two rules. Optional parameters come after the required ones, and the default value must be a constant known at compile time:
Console.WriteLine(Shipping.Fee(2.0m, "DE"));
static class Shipping
{
public static decimal Fee(decimal weightKg = 1.0m, string country) => 0m;
public static DateTime Deadline(DateTime start = DateTime.Today) => start;
}
Program.cs(5,70): error CS1737: Optional parameters must appear after all required parameters
Program.cs(7,54): error CS1736: Default parameter value for 'start' must be a compile-time constant
DateTime.Today is a value that changes every day, so it cannot be a default. A common fix in such cases is to make the parameter DateTime? and write start ?? DateTime.Today inside the method.
ref, out and in
If you want to change the behavior from the Guess First section on purpose, you use ref. A ref parameter does not take a copy of the value but the caller's variable itself:
int stock = 5;
Inventory.Restock(ref stock, 10);
Console.WriteLine(stock);
if (Inventory.TryReserve(stock, 3, out int remaining))
{
Console.WriteLine($"Reserved, {remaining} left");
}
if (!Inventory.TryReserve(stock, 50, out _))
{
Console.WriteLine("Not enough stock");
}
static class Inventory
{
public static void Restock(ref int stock, int amount)
{
stock += amount;
}
public static bool TryReserve(int stock, int quantity, out int remaining)
{
if (quantity > stock)
{
remaining = stock;
return false;
}
remaining = stock - quantity;
return true;
}
}
15
Reserved, 12 left
Not enough stock
When we wrote stock += amount inside Restock, the stock on the calling side changed. ref must be written both in the declaration and in the call; the caller opens this door knowing that its variable may change:
int stock = 5;
Inventory.Restock(stock, 10);
static class Inventory
{
public static void Restock(ref int stock, int amount) => stock += amount;
}
Program.cs(2,19): error CS1620: Argument 1 must be passed with the 'ref' keyword
out is the way for a method to give the caller an extra result. The "Try" pattern you know from int.TryParse in .NET is exactly this: the method returns success as a bool and hands out the actual result through an out parameter. out int remaining declares the variable during the call; if you do not care about the result, you can write out _ and throw it away. The compiler wants to be sure the out parameter is assigned on every path out of the method:
Console.WriteLine(Inventory.TryReserve(5, 3, out int left));
static class Inventory
{
public static bool TryReserve(int stock, int quantity, out int remaining)
{
if (quantity > stock)
{
return false;
}
remaining = stock - quantity;
return true;
}
}
Program.cs(9,13): error CS0177: The out parameter 'remaining' must be assigned to before control leaves the current method
An in parameter is also passed by reference, but it is read-only; the method cannot change it:
Console.WriteLine(Pricing.WithTax(100m, 0.18m));
static class Pricing
{
public static decimal WithTax(in decimal price, decimal rate)
{
price = price * (1m + rate);
return price;
}
}
Program.cs(7,9): error CS8331: Cannot assign to variable 'price' or use it as the right hand side of a ref assignment because it is a readonly variable
The purpose of in is to pass large structs without copying them; with small types like decimal there is no gain, and you rarely see it in everyday code. Let me give a similar warning for ref: to return more than one result, returning a tuple or a small type is usually more readable than ref or out. Keep out for the "Try" pattern and ref for the places that really need it.
params
For a method that takes a variable number of arguments, you use params:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Pricing.Sum());
Console.WriteLine(Pricing.Sum(12.5m));
Console.WriteLine(Pricing.Sum(12.5m, 8.9m, 21m));
decimal[] cart = { 5m, 5m };
Console.WriteLine(Pricing.Sum(cart));
static class Pricing
{
public static decimal Sum(params decimal[] prices)
{
decimal total = 0m;
foreach (decimal price in prices)
{
total += price;
}
return total;
}
}
0
12.5
42.4
10
params decimal[] prices lets the caller list the arguments separated by commas; the compiler puts them into an array for you. If you pass no arguments, you get an empty array, not null. If you already have an array, you can pass it directly too. The params parameter must be last in the list, and a method can have only one. This is also how Console.WriteLine and string.Format can take a variable number of values. With C# 13, params started working not only with arrays but also with collections such as List<T> and Span<T>; we will see that in the C# Versions series.
Local Functions
Inside a method you can declare a small helper method that only that method will use:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
decimal[] prices = { 12.50m, 8.90m, 21.00m };
Console.WriteLine(Report(prices, 0.18m));
static string Report(decimal[] prices, decimal taxRate)
{
decimal total = 0m;
foreach (decimal price in prices)
{
total += WithTax(price);
}
return $"{prices.Length} books, total {total:0.00}";
decimal WithTax(decimal price) => price * (1m + taxRate);
}
3 books, total 50.03
WithTax lives inside Report and is not visible from outside. Notice that it did not take taxRate as a parameter; it reached the variable of the method it sits in directly. This is called capturing. It is handy, but it also creates a hidden dependency; you can forbid it by writing a static local function:
Console.WriteLine(Total(new[] { 10m, 20m }, 0.18m));
static decimal Total(decimal[] prices, decimal taxRate)
{
return Sum() * (1m + taxRate);
static decimal Sum()
{
decimal total = 0m;
foreach (decimal price in prices)
{
total += price;
}
return total;
}
}
Program.cs(10,35): error CS8421: A static local function cannot contain a reference to 'prices'.
When you write static, the local function cannot reach outer variables and has to take everything it needs as parameters. That makes it independent and easy to test.
Every function you declare in a file written with top-level statements is also a local function. That has an important consequence: local functions cannot be overloaded.
Console.WriteLine(Describe(3));
Console.WriteLine(Describe("Dune"));
string Describe(int quantity) => $"{quantity} copies";
string Describe(string title) => $"Title: {title}";
Program.cs(2,28): error CS1503: Argument 1: cannot convert from 'string' to 'int'
Program.cs(5,8): error CS0128: A local variable or function named 'Describe' is already defined in this scope
Program.cs(5,8): warning CS8321: The local function 'Describe' is declared but never used
If you want two functions with the same name, move them into a class; methods inside a class can be overloaded. That is also the answer to why we use static class Pricing in our examples.
Expression-Bodied Members
You can write methods whose body is a single expression briefly with =>. The same syntax works for properties too:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var book = new Book("Dune", 12.50m, 3);
Console.WriteLine(book.Label());
Console.WriteLine(book.IsAvailable);
class Book
{
public Book(string title, decimal price, int stock)
{
Title = title;
Price = price;
Stock = stock;
}
public string Title { get; }
public decimal Price { get; }
public int Stock { get; }
public bool IsAvailable => Stock > 0;
public string Label() => $"{Title} ({Price:0.00})";
}
Dune (12.50)
True
Label() is a method, and IsAvailable is a property that is recalculated every time it is read. Both were written with =>. This form was also in the Total and DiscountRate methods we have used since the start of the post. The rule is simple: if the body is a short, readable expression, use =>; once an if, a loop or more than one step starts creeping in, go back to a normal body with braces. We will look at constructors and properties in detail in the OOP series.
Common Pitfalls
Changing a parameter and thinking the caller's value changed too. That is the example from the start of the post; return the result with return. But with reference types like arrays, a method that changes the elements is visible to the caller, as we saw with ResetStock in the previous post. The whole of that difference is the topic of the next post.
Changing the default value of an optional parameter later. The compiler bakes the default value into the calling code. If you change country = "TR" to "DE" in a library, code that uses the library but is not recompiled keeps using the old value. In public APIs, treat default values as a fixed contract.
Lots of parameters and bool flags. A call like Fee(2.0m, "DE", true, false, true) is a sign that the method does too much. Either split the method or group the parameters into a meaningful type.
Writing everything into one giant method. If you cannot say a method's name without "and" (ValidateAndSaveAndNotify), it probably does more than one job. Small methods whose names say what they do are easier both to read and to test.
A Small Experiment
★☆☆ Write a FormatPrice method with two overloads: one takes a decimal and writes it as "12.50", the other takes a decimal and a currency string and returns "12.50 EUR". Do not forget to set the culture to InvariantCulture.
★★☆ Write a bool TryParseIsbn(string text, out string isbn) method: it removes the hyphens and returns true if the remaining text is a 13-digit number. Call it once with out _ and once with a real variable.
★★★ Write an Average method that takes params. What should it return when no arguments are given: 0, or an exception? Try both options and discuss which one is more honest to the caller.
In this post we saw that int and decimal are copied into a method while an array goes in by reference, but we kept putting off the reason. In the next post we take on the value type and reference type difference: assignment and copy semantics, passing to methods, the special case of string, and explaining the stack and the heap accurately and without exaggeration.