<< Home

Chapter 6: Methods and Parameters

Methods package behavior behind a name; they take parameters, produce return values, and form the core of most program logic. A clear method signature tells callers what they can expect. This chapter explores parameters, overload rules, local functions, expression-bodied members, and the subtleties of recursion.

Method signatures and return values

A method signature includes its name, parameter list, and any type parameters when generics are involved. The return type describes the value produced. A method with no meaningful return uses void. If a method may fail, prefer exceptions over sentinel values because they make error paths explicit.

Declaring methods

Each method specifies visibility, modifiers, return type, name, and parameters in that order. Curly braces hold the body.

public int Add(int a, int b) {
  return a + b;
}

Returning values

The return statement ends execution and yields the result. Use a single return when clarity demands it or multiple returns when guarding early. Both styles are valid when used with care.

public string Classify(int score) {
  if (score < 0) { return "invalid"; }
  if (score < 50) { return "fail"; }
  return "pass";
}
💡 Prefer clear naming and simple logic inside methods. When they grow long, extract helper methods to keep each piece focused.

Expression-bodied methods

When a method only returns the result of one expression, the => form provides a compact style. Properties, indexers, operators, and local functions can also use this pattern.

public int Square(int x) => x * x;

Parameters

By default parameters pass by value which means the method receives a copy for value types or a copy of the reference for reference types. The ref, in, and out modifiers change how arguments flow between caller and callee and they affect mutability and assignment rules. These features should be used deliberately; they make data flow explicit.

ref parameters

ref passes a variable by reference; the method receives a reference to the caller’s storage. The caller must initialize the variable before passing it. Assigning to the parameter changes the original variable.

void Increase(ref int value) {
  value++;
}

int n = 5;
Increase(ref n); // n becomes 6

out parameters

out passes a variable that the method guarantees to assign before returning. This is common when a method needs to return multiple values or signal whether parsing succeeded.

if (int.TryParse("123", out int result)) {
  Console.WriteLine(result);
}

in parameters

in passes a reference that is read-only. It is often used with large structs to avoid copying but still prevent modification. The caller must initialize the variable before passing it.

readonly struct Point {
  public int X { get; }
  public int Y { get; }
  public Point(int x, int y) { X = x; Y = y; }
}

int Dist(in Point p) {
  return p.X * p.X + p.Y * p.Y;
}
⚠️ Avoid overusing ref and out; they can obscure data flow. Consider returning a tuple or defining a struct when you need to return several values together.

Optional and named arguments

Optional parameters specify default values. Callers can omit them when the defaults suit their needs. Named arguments improve clarity at the call site by pairing each argument with its parameter name.

Declaring optional parameters

Optional values must be compile-time constants or default. Place optional parameters after required ones to avoid ambiguity.

void Log(string message, bool timestamp = true) {
  if (timestamp) {
    Console.Write($"[{DateTime.Now}] ");
  }
  Console.WriteLine(message);
}

Using named arguments

Named arguments document intent especially when calls include many Boolean or numeric parameters. Names can appear in any order after positional ones.

Log(message: "Starting", timestamp: false);
💡 Use named arguments to improve clarity but avoid changing names frequently; callers depend on stable parameter names.

Overloading and resolution

Methods with the same name may have different parameter lists. This is overloading. The compiler chooses the best candidate based on argument types, conversions, and generic inference. Overloads should form a coherent family with clear and consistent semantics.

Creating overload sets

Each overload must differ in parameter count or types. Return type alone cannot distinguish overloads. Keep overload sets tidy so callers can rely on predictable behavior.

public void Write(string s) { Console.WriteLine(s); }
public void Write(int n)    { Console.WriteLine(n); }

Overload resolution rules

The compiler considers exact matches first then widening conversions then applicable generic methods. Ambiguous calls produce errors so keep overloads distinct enough that typical calls remain unambiguous.

void F(long x) { Console.WriteLine("long"); }
void F(double x) { Console.WriteLine("double"); }

F(3); // chooses F(long) because int widens to long
⚠️ Too many overloads can confuse readers. Consider using optional parameters or carefully designed types when the overload list grows unwieldy.

Local functions

Local functions live inside another method. They help decompose logic without exposing helper methods at the class level. Expression-bodied members provide compact syntax for simple operations and can be applied to local functions too. Both features keep related logic close together and support clear layering.

Defining local functions

Local functions have full access to variables in the enclosing method. They avoid allocations that often arise with lambdas and can improve readability by grouping steps together.

int SumTo(int limit) {
  int Acc(int n) {
    return n == 0 ? 0 : n + Acc(n - 1);
  }
  return Acc(limit);
}

Expression-bodied members

Any method or local function that consists of one simple expression can use the => form. This keeps focus on what the method returns rather than the boilerplate structure.

string Greet(string name) => $"Hello, {name}";
💡 Use the compact form for simple functions; switch back to block bodies when logic becomes more complex.

Recursion

Recursion occurs when a method calls itself. It is a powerful technique for problems that break into smaller parts. Each call pushes a new frame on the stack; deep recursion may exhaust stack space. Tail-call optimization can reduce stack use in some cases although its availability depends on runtime conditions and method characteristics.

Typical recursive structure

A recursive method must define a base case that ends the recursion and a recursive step that moves toward that base. Without both it risks infinite recursion.

int Fact(int n) {
  if (n <= 1) { return 1; }
  return n * Fact(n - 1);
}

Tail-call opportunities

A tail call occurs when the recursive call is the method’s final action. Some runtimes can optimize this by reusing the current stack frame. C# does not guarantee tail-call optimization; consider iterative forms when call depth might be large.

int FactTail(int n, int acc) {
  if (n <= 1) { return acc; }
  return FactTail(n - 1, n * acc); // tail position
}
⚠️ Even when a call is in tail position the optimizer may not apply tail-call elimination. When performance or depth safety matters, prefer loops.