Layers of Logic

5.1

Methods, Calls, Recursion, and Overloading

Learn methods from the first call to overload resolution, with the call stack connecting every step.

Core28 min read5 exercises
01

Previously on

You can store values, make decisions, repeat work, and process arrays. Most of that work still lives inside main.

This lesson gives code a reusable boundary. By the end, you will be able to define a method, trace its data, and explain every active call.

02

The problem

This program prints a greeting twice:

public class MethodDemo {
    public static void main(String[] args) {
        System.out.println("Welcome to the registry");
        System.out.println("Choose an operation");

        System.out.println("Welcome to the registry");
        System.out.println("Choose an operation");
    }
}

The duplicate lines are a small maintenance problem. They also hide an idea that deserves a name: show the menu.

A method groups statements behind a name. Calling that name runs the grouped work.

You have already called methods. println is one. The new step is defining your own.

03

The idea

Define and call a method

public class MethodDemo {
    static void showMenu() {
        System.out.println("Welcome to the registry");
        System.out.println("Choose an operation");
    }

    public static void main(String[] args) {
        showMenu();
        showMenu();
    }
}

The method belongs inside the class but outside main. Its declaration has four parts:

static    void    showMenu    ()
modifier  result  name        parameters

static lets this early program call the method without creating an object. Instance methods arrive with classes and objects in Phase VI.

void means the call produces no result value. showMenu is the method name. Empty parentheses mean it accepts no arguments.

The line showMenu(); is a method call. Execution enters the method body, runs its statements, then continues after the call.

Add one parameter

Hard-coded methods can repeat work, but they cannot adapt. A parameter lets the caller provide a value.

static void greet(String name) {
    System.out.println("Hello, " + name);
}

public static void main(String[] args) {
    greet("Asha");
    greet("Kabir");
}

Expected output:

Hello, Asha
Hello, Kabir

The relationship is exact:

  • "Asha" is an argument in the call.
  • name is a parameter in the declaration.
  • The parameter is a local variable available only during that call.

Each call gets its own name. One call does not overwrite the parameter of another finished call.

Use several parameters

static void printScore(String name, int score) {
    System.out.println(name + ": " + score);
}

printScore("Asha", 91);

Arguments match parameters by position. The first argument supplies name; the second supplies score.

The count and types must be compatible. printScore(91, "Asha") fails because an int cannot supply the String parameter.

Return a result

A method can calculate a value and give it back to its caller.

static int larger(int a, int b) {
    if (a >= b) {
        return a;
    }
    return b;
}

The declared return type is int. Every normal path through this method returns an int value.

The call itself is an expression of type int:

int winner = larger(12, 19);
System.out.println(larger(4, 7));
int total = larger(3, 8) + 10;

In each line, Java evaluates the call first. The returned value then takes the call’s place in the surrounding expression.

return has two effects. It supplies the result, and it ends the current method call immediately.

static boolean isPositive(int value) {
    if (value > 0) {
        return true;
        // code here would be unreachable
    }
    return false;
}

A void method may use return; with no value to leave early.

static void printPositive(int value) {
    if (value <= 0) {
        return;
    }
    System.out.println(value);
}

Extract a complete piece of work

The repeated maximum loop can become one method:

static int highestIn(int[] values) {
    if (values.length == 0) {
        throw new IllegalArgumentException("values must not be empty");
    }

    int highest = values[0];
    for (int i = 1; i < values.length; i++) {
        if (values[i] > highest) {
            highest = values[i];
        }
    }
    return highest;
}

Now highestIn(scores) tells the reader what the loop accomplishes. It also keeps the empty-array rule next to the implementation that depends on it.

04

Under the hood

Going deeper

Arguments are evaluated before the call

Consider this program:

static int announce(int value) {
    System.out.println("evaluated " + value);
    return value;
}

static void show(int left, int right) {
    System.out.println(left + right);
}

public static void main(String[] args) {
    show(announce(2), announce(5));
}

Java evaluates argument expressions from left to right. The output is:

evaluated 2
evaluated 5
7

Only after both argument values exist does show begin.

Java always passes values

For a primitive, the value is the primitive data:

static void change(int number) {
    number = 99;
}

int original = 10;
change(original);
System.out.println(original);  // 10

The parameter receives a copy of 10. Assigning 99 changes only the parameter.

For an array or object, the value is a reference:

static void changeFirst(int[] numbers) {
    numbers[0] = 99;
}

int[] original = {10, 20};
changeFirst(original);
System.out.println(original[0]);  // 99

The parameter receives a copy of the reference. Both references reach the same array, so mutation is visible through either one.

Reassigning the parameter is different:

static void replace(int[] numbers) {
    numbers = new int[] {99, 100};
}

int[] original = {10, 20};
replace(original);
System.out.println(original[0]);  // 10

The parameter now points elsewhere. The caller’s variable still holds its original reference.

Java is therefore pass-by-value in both examples. The type of value differs.

Local variables have method scope

Parameters and variables declared inside a method are local to that invocation.

static int square(int value) {
    int result = value * value;
    return result;
}

Neither value nor result can be named from main. A later call gets new locals, even if an earlier call used the same values.

Local variables do not receive automatic default values. A local must definitely be assigned before Java lets you read it.

static int choose(boolean first) {
    int result;
    if (first) {
        result = 10;
    }
    return result; // compile-time error: result may be uninitialised
}

The compiler examines paths, not your intention. There is a path where first is false and no assignment occurs.

Every active call has a stack frame

The call stack records methods that have started but not finished. A call adds a frame. Returning removes that frame.

static int doubleValue(int n) {
    return n * 2;
}

static int addThenDouble(int a, int b) {
    int sum = a + b;
    return doubleValue(sum);
}

public static void main(String[] args) {
    int answer = addThenDouble(3, 4);
}

At the deepest point, the stack is:

top     doubleValue: n = 7
        addThenDouble: a = 3, b = 4, sum = 7
bottom  main

doubleValue returns 14, so its frame is removed. addThenDouble then returns 14, and its frame is removed.

A frame holds bookkeeping needed for that invocation. This includes parameters, local values, operand data, and a return point in the JVM model.

This model explains stack traces. The top entry is where the failure occurred. Lower entries show the unfinished callers that led there.

Recursion is an ordinary method calling itself

static int factorial(int n) {
    if (n < 0) {
        throw new IllegalArgumentException("n must be non-negative");
    }
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

A correct recursive method needs two properties:

  1. A base case that returns without another recursive call.
  2. A recursive step that moves the input toward that base case.

Trace factorial(4) before running it:

factorial(4) waits for 4 * factorial(3)
factorial(3) waits for 3 * factorial(2)
factorial(2) waits for 2 * factorial(1)
factorial(1) returns 1
factorial(2) returns 2
factorial(3) returns 6
factorial(4) returns 24

The calls grow the stack on the way down. Returns unwind it on the way back.

If the input never reaches a base case, each unfinished call adds another frame. Eventually the thread cannot create another frame and throws StackOverflowError.

Java does not promise tail-call elimination. A recursive solution that may be extremely deep should usually become a loop or use an explicit data structure.

Overloading gives one name to several parameter lists

These methods are overloads:

static int area(int side) {
    return side * side;
}

static int area(int width, int height) {
    return width * height;
}

static double area(double radius) {
    return Math.PI * radius * radius;
}

An overload must have a different parameter list. The method’s signature consists of its name and parameter types.

Parameter names and return types do not distinguish overloads:

static int parse(String text) { return 1; }
static double parse(String value) { return 1.0; } // duplicate signature

The compiler chooses an overload using the compile-time types of the argument expressions. The return destination does not choose it.

A practical overload-resolution sequence

For a fixed call, use this sequence:

  1. Look for applicable fixed-arity methods under strict invocation. Exact matches and primitive widening are available here.
  2. If none apply, allow loose invocation conversions such as boxing and unboxing.
  3. If none apply, consider variable-arity methods.
  4. If several methods survive one phase, choose the most specific applicable method.
  5. If there is no unique most specific method, the call is ambiguous.
static void pick(long value)    { System.out.println("long"); }
static void pick(Integer value) { System.out.println("Integer"); }
static void pick(int... values) { System.out.println("varargs"); }

pick(1); // long

The literal 1 has type int. Primitive widening to long is available in the first phase. Boxing is not considered because an applicable method already exists.

Varargs are syntax for receiving an array:

static int sum(int... values) {
    int total = 0;
    for (int value : values) {
        total += value;
    }
    return total;
}

sum();
sum(4);
sum(4, 5, 6);

Inside sum, values has type int[]. At a variable-arity call site, the compiler arranges the supplied values into an array.

Declared types can change overload selection

static void describe(Object value) { System.out.println("object"); }
static void describe(String value) { System.out.println("string"); }

String text = "hi";
Object general = text;

describe(text);    // string
describe(general); // object

Both variables refer to the same object. Overloading is still chosen from their compile-time types.

Overriding behaves differently because it uses the object’s run-time class. Phase VII separates those mechanisms in full.

null can expose ambiguity:

static void load(String value) { }
static void load(Integer value) { }

// load(null); // ambiguous: neither parameter type is more specific than the other

The compiler refuses to guess.

05

What it costs

Small methods improve naming, reuse, testing, and stack traces. Fragmenting one idea across many tiny methods can make the path harder to follow.

Overloading is useful when operations have one meaning across closely related inputs. Unrelated behaviour behind one name makes calls harder to predict.

Recursion can mirror tree and divide-and-conquer problems well. A loop is clearer for many linear repetitions and does not consume one frame per step.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. What is the difference between an argument and a parameter?

    Show the answer

    An argument is an expression at the call site. A parameter is a local variable declared by the method. Java evaluates each argument, copies the resulting value into its matching parameter, and starts the method body.

  2. A method receives an array and changes one element. Java is pass-by-value, so how can the caller observe the change?

    Show the answer

    The copied value is a reference. The caller and parameter hold separate copies of the same reference, so both can reach one array object. Reassigning the parameter changes only its local copy. Mutating the shared array changes the object both references reach.

  3. Why does recursion without a reachable base case normally end in StackOverflowError?

    Show the answer

    Every unfinished call needs a stack frame. A recursive call pushes another frame before the current call can return. If the base case is never reached, frames keep accumulating until that thread's stack has no room for another call.

  4. Given pick(long x) and pick(Integer x), which overload handles pick(1)?

    Show the answer

    pick(long) handles it. Strict fixed-arity matching allows primitive widening from int to long. The compiler only considers boxing during a later loose-invocation phase if strict matching found no applicable method.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

5 exercises115 pointsabout 135 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Extract Three Loops Into One Method

Warm up·20 min·15 points

checkedex-5-1-a

Three copies of one idea. Pull it out into a method, then add two more like it.

This is the most common refactoring you will ever do, and doing it once on purpose is worth more than reading about it.

Watch the average. Both the total and the length are ints, so the division is int division and your decimal disappears. That is the same bug from Section 2.3, showing up in real code where it is much harder to see.

Then answer the last question properly. Removing repetition is the obvious gain. There is a second one, and it is the reason this refactoring is worth doing even when the code appears only once.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Extract {
    static int highestIn(int[] values)
    static int lowestIn(int[] values)
    static double averageOf(int[] values)
    static int rangeOf(int[] values)
}

All four work on any int array, including one that is entirely negative. The average must not overflow on large values.

What your program must do

  • Replace three copied loops with one method
  • Add lowest, average and range
  • Make it work on an array of negatives
  • Say what you would have to change if the same bug were in all three copies
Extract.java
public class Extract {

    // One method that works for any array, replacing all three copied loops.
    static int highestIn(int[] values) { return 0; }  // TODO
    static int lowestIn(int[] values)  { return 0; }  // TODO
    static double averageOf(int[] values) { return 0; }  // TODO
    static int rangeOf(int[] values)   { return 0; }  // TODO: highest minus lowest

    public static void main(String[] args) {
        int[] scores       = {78, 65, 91, 54, 88};
        int[] temperatures = {31, 28, 35, 22, 30};
        int[] distances    = {120, 340, 90, 500, 210};
        // TODO: call one method three times instead of writing three loops
    }
}
Hint 1
Start highestIn at values[0]. Starting at 0 gives the wrong answer for an array where everything is negative.
Hint 2
Add into a long before dividing. Two values near Integer.MAX_VALUE will overflow an int total before you get to the division.
Hint 3almost the answer
That is the real argument for extracting it. Three copies means finding and fixing the same bug three times, and missing one is the usual outcome.
What this is really testing

Whether you can spot repeated logic and pull it out. This is the single most common refactoring in real work, and doing it deliberately once builds the reflex.

B

Draw the Stack

Real work·25 min·20 points

checkedex-5-1-b

Draw the call stack by hand, then check yourself against a real one.

The program deliberately prints a stack trace from the deepest point, using an exception it never throws. Read that trace and match every line to a box in your drawing. They should correspond exactly, and seeing that correspondence once changes how you read every stack trace afterwards.

Predict the result before running. It is simple arithmetic, and the point is to trace the values through the frames rather than through the source.

Finish by explaining why three variables named n do not collide. If you can say that clearly, you understand what a frame is.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Frames {
    static List<String> currentFrames()
    static int depthNow()
    static List<String> level1(int n)
    static List<String> level2(int n)
    static List<String> level3(int n)
    static int level1Value(int n)
    static int level2Value(int n)
    static int level3Value(int n)
}

currentFrames returns the names of the methods on the stack right now, innermost first, filtered to this class only. The three value methods do the arithmetic: level1 adds 10 and calls level2, which doubles and calls level3, which adds one.

What your program must do

  • Print the stack from the deepest method and draw it on paper
  • Show that the frames disappear as the methods return
  • Predict the arithmetic result before running
  • Say what each frame is holding
Frames.java
import java.util.*;

public class Frames {

    // The names of OUR methods on the stack right now, innermost first.
    static List<String> currentFrames() { return List.of(); }  // TODO
    static int depthNow() { return 0; }  // TODO

    // These three just report the stack from the bottom of the chain.
    static List<String> level3(int n) { return List.of(); }  // TODO
    static List<String> level2(int n) { return List.of(); }  // TODO: call level3 with n * 2
    static List<String> level1(int n) { return List.of(); }  // TODO: call level2 with n + 10

    // The same chain, doing arithmetic. Predict level1Value(5) before running.
    static int level3Value(int n) { return 0; }  // TODO: n + 1
    static int level2Value(int n) { return 0; }  // TODO: level3Value(n * 2)
    static int level1Value(int n) { return 0; }  // TODO: level2Value(n + 10)

    public static void main(String[] args) {
        // TODO: print the frames from the bottom of the chain, and draw the stack
        // TODO: predict level1Value(5) BEFORE running it
    }
}
Hint 1
new Exception().getStackTrace() gives you the frames without throwing anything. Filter to your own class or the JUnit machinery drowns it.
Hint 2
Innermost first. The method that is running is at the top, and main is at the bottom.
Hint 3almost the answer
Each frame holds that call's own copy of n. Three frames means three different values of a variable with one name, which is why the arithmetic threads through the way it does.
What this is really testing

Whether the call stack is a picture you can draw. Once you can draw it, stack traces stop being noise and start being a story about how your program got somewhere.

C

Recursion, and Where It Runs Out

Real work·30 min·25 points

checkedex-5-1-c

Three recursive methods, then a deliberate crash.

For each one, write down the base case before you write any code. That is the part people skip, and skipping it is what produces the crash at the bottom of this file.

Then run runaway on purpose and find out how deep your machine actually goes. The number will surprise you: not millions, and not a hundred. Somewhere in the tens of thousands, which is close enough to real problem sizes to matter.

Finish by rewriting factorial as a loop and comparing. For factorial the loop is at least as clear and has no depth limit, which is worth noticing. Recursion earns its cost on trees and nested structures, not on counting.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Recurse {
    static long factorial(int n)
    static int sumDigits(int n)
    static boolean isPalindrome(String s, int left, int right)
    static long factorialLoop(int n)
    static int depthReached()
}

depthReached deliberately recurses until the stack runs out and returns how deep it got, catching the StackOverflowError itself. sumDigits must handle a negative input.

What your program must do

  • Write all three recursive methods with a correct base case
  • Write factorial again as a loop and confirm they agree
  • Find how deep recursion goes on your machine before it runs out
  • Say what each frame costs and why the loop version has no limit
Recurse.java
public class Recurse {

    static long factorial(int n) { return 0; }  // TODO

    static int sumDigits(int n) { return 0; }  // TODO: sumDigits(1234) is 10

    static boolean isPalindrome(String s, int left, int right) { return false; }  // TODO

    // The same job with a loop, so nothing goes on the stack.
    static long factorialLoop(int n) { return 0; }  // TODO

    // Recurse until the stack runs out. Catch the error and report the depth.
    static int depthReached() { return 0; }  // TODO

    public static void main(String[] args) {
        // TODO: check all three against known answers
        // TODO: find how deep this machine lets you go, then try -Xss256k
    }
}
Hint 1
Every recursive method needs a base case that returns without calling itself. Write that line first, before the recursive one.
Hint 2
isPalindrome stops when left >= right, which covers both meeting in the middle and crossing over on an even length.
Hint 3almost the answer
Catch StackOverflowError inside the recursion itself and return the depth. The number is a fact about your stack size, so -Xss changes it, which tells you what the limit is made of.
What this is really testing

Whether you can write recursion with a correct base case, and whether you know its limit is a real engineering constraint rather than a theoretical one.

D

Which Overload Wins

Hard·30 min·30 points

checkedex-5-1-d

Seven calls. Predict all seven before you run anything, and write down a reason for each.

Do not guess by which one “looks closest”. Work through strict fixed arity, loose fixed arity, then variable arity. Stop at the first phase with applicable methods, then choose the most specific one.

Two of these are genuinely surprising. f('a') has no char version and still resolves without complaint. And g(1) picks the long version over the Integer version, which most people get wrong.

Finish by breaking it on purpose. Add a method that makes one of the calls ambiguous, and read the compiler error. Seeing the compiler admit it cannot choose is the clearest proof that these rules are real and not folklore.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Which {
    static String f(int x)
    static String f(long x)
    static String f(double x)
    static String f(Integer x)
    static String f(Object x)
    static String f(int... x)
    static String g(long x)
    static String g(Integer x)
    static String h(Integer x)
    static String h(int... x)
    static String passThatWins(String call)
}

Each overload returns its own parameter type as a string, so the checks can see which one was chosen. passThatWins takes a call written as text and names the pass that decided it.

What your program must do

  • Predict which overload wins for all seven calls before running
  • Say which invocation phase decided each one
  • Explain why g(1) does not pick Integer
  • Explain why varargs is genuinely last
Which.java
public class Which {

    // Each one returns its own parameter type, so you can see which was picked.
    static String f(int x)     { return ""; }  // TODO
    static String f(long x)    { return ""; }  // TODO
    static String f(double x)  { return ""; }  // TODO
    static String f(Integer x) { return ""; }  // TODO
    static String f(Object x)  { return ""; }  // TODO
    static String f(int... x)  { return ""; }  // TODO

    static String g(long x)    { return ""; }  // TODO
    static String g(Integer x) { return ""; }  // TODO

    static String h(Integer x) { return ""; }  // TODO
    static String h(int... x)  { return ""; }  // TODO

    // Which invocation phase decided this call?
    static String passThatWins(String call) { return ""; }  // TODO

    public static void main(String[] args) {
        // PREDICT each of these before running:
        //   f((byte) 1)  f(1)  f(1L)  f('a')  f(1.5f)  g(1)  h(1)
    }
}
Hint 1
Three phases, in order. One: strict fixed-arity invocation, including exact matches and primitive widening. Two: loose fixed-arity invocation, which can use boxing or unboxing. Three: variable arity. If several methods survive one phase, the compiler still chooses the unique most specific method.
Hint 2
g(1) picks long because an int widens to a long in pass one, so pass two never runs and Integer never gets considered.
Hint 3almost the answer
h(1) picks Integer because boxing is pass two and varargs is pass three. Varargs is the last resort, which is why adding one to a class can quietly change which method a call goes to.
What this is really testing

Whether you can apply strict fixed-arity, loose fixed-arity, and variable-arity invocation before comparing the surviving methods for specificity.

E

The Registry Grows Methods

Real work·30 min·25 points·The Registry

checkedex-5-1-e

Your Registry has been one long main for two phases. Break it into named methods.

Every method here is small. The exercise is not the code, it is the naming and the structure. When you are done, main should read like a description of what the program does, and each method should do one thing you could describe in a sentence.

Two things to get right.

Use .equals() for the name comparison. == will compile, run, and silently return -1 for a name that is clearly in the array. That is Section 3.2 coming back at exactly the moment you stopped thinking about it.

Make the overload share its implementation. Two versions of averageReadiness should not contain the logic twice. The no-argument version should be one line that calls the other. Two copies of anything is two things that can drift apart.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class RegistryOps {
    static String[] names()
    static int[] ids()
    static double[] readiness()
    static boolean[] active()
    static int findById(int[] ids, int id)
    static int findByName(String[] names, String name)
    static double averageReadiness(double[] readiness)
    static int countDeployable(double[] readiness, boolean[] active)
    static String describe(String[] names, double[] readiness, int index)
    static String describe(String[] names, int[] ids, double[] readiness, int id)
}

The two describe methods are overloads: one takes an index, the other an id. Both return "Name at Readiness" or "unknown". Finders return -1 for a miss.

What your program must do

  • Write finders that return an index, and -1 for a miss
  • Write two describe overloads that agree for the same unit
  • Handle a unit that is not in the roster
  • Say why the id overload can be written in terms of the index one
RegistryOps.java
public class RegistryOps {

    static String[]  names()     { return new String[]{"Atlas", "Beacon", "Cipher", "Drift", "Ember"}; }
    static int[]     ids()       { return new int[]{101, 102, 103, 104, 105}; }
    static double[]  readiness() { return new double[]{88.5, 42.0, 95.5, 61.0, 73.5}; }
    static boolean[] active()    { return new boolean[]{true, true, false, true, true}; }

    static int findById(int[] ids, int id) { return -1; }  // TODO
    static int findByName(String[] names, String name) { return -1; }  // TODO

    static double averageReadiness(double[] readiness) { return 0; }  // TODO
    static int countDeployable(double[] readiness, boolean[] active) { return 0; }  // TODO

    // Two overloads: one takes an index, one takes an id.
    // Both return "Name at Readiness", or "unknown".
    static String describe(String[] names, double[] readiness, int index) { return ""; }  // TODO
    static String describe(String[] names, int[] ids, double[] readiness, int id) { return ""; }  // TODO

    public static void main(String[] args) {
        // TODO: use both overloads and confirm they agree for the same unit
    }
}
Hint 1
Return an index rather than a value. A finder that returns the readiness cannot tell you the name, and a finder that returns the name cannot tell you anything else.
Hint 2
Compare names with equals. == works for the literals in the array and fails the moment a name comes from input, which is Section 3.2.
Hint 3almost the answer
The id overload should find the index and then hand off to the other one. Two overloads doing the same work twice is how they drift apart.
What this is really testing

Whether you can restructure a long main into named pieces, and whether you use overloading where it genuinely helps rather than everywhere you can.

08

After the credits

You should now be able to trace a call using five questions:

  1. Which declaration did the compiler select?
  2. In what order are the arguments evaluated?
  3. Which values are copied into the parameters?
  4. What frame is added to the stack?
  5. Which value, if any, replaces the call when it returns?

Objects add a receiver to this model. The call mechanics remain.

Threads you opened in this section

Method overloading will return in 6.2 - Constructors, Chaining, `this`