Functions #
In Java, all functions are called methods — they’re always part of a class and can’t stand alone like in Python or Go. A method is the smallest unit of logic that can be named and called again. Writing good methods is one of the most important skills in Java: methods that are too long are hard to understand, methods with too many parameters are hard to call, and methods that do too many things are hard to test. This article covers method anatomy, the various types and modifiers, the often-misunderstood pass-by-value behavior, overloading, varargs, recursion, and an introduction to lambdas as the modern way to write anonymous functions.
Method Anatomy #
Every method in Java consists of several parts, each with its own role:
// [1] [2] [3] [4] [5] [6]
public static int calculateArea(int length, int width) throws IllegalArgumentException {
// [7]
if (length <= 0 || width <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
return length * width; // [8]
}
| Part | Example | Description |
|---|---|---|
| [1] Access modifier | public | Who can call this method |
| [2] Non-access modifier | static | Additional behavior (static, final, abstract, etc.) |
| [3] Return type | int | The type of the returned value; void if none |
| [4] Method name | calculateArea | camelCase, starts with a verb |
| [5] Parameters | int length, int width | The inputs the method receives |
| [6] Throws clause | throws IllegalArgumentException | Checked exceptions that may be thrown |
| [7] Body | {} block | The method’s logic |
| [8] return | return length * width | The value returned to the caller |
Method Modifiers #
Access Modifiers #
Access modifiers determine from where a method can be called:
public class ModifierExample {
public void publicMethod() { } // from anywhere
protected void protectedMethod() { } // this class + subclasses + same package
void defaultMethod() { } // same package only
private void privateMethod() { } // this class only
}
static vs Instance #
static means the method belongs to the class, not an object. It can be called without creating an object:
public class MathUtils {
// static — belongs to the class, no object needed
public static int square(int n) {
return n * n;
}
// instance — needs an object, can access instance fields
private double factor;
public double scale(double value) {
return value * this.factor; // access the instance field via this
}
}
// Calling
int result = MathUtils.square(5); // directly via the class name
MathUtils util = new MathUtils();
double scaled = util.scale(3.14); // needs an object
final on Methods #
public class Parent {
// final — can't be overridden in subclasses
public final void validate() {
// validation logic that must not change
}
// Without final — can be overridden
public void process() { }
}
public class Child extends Parent {
// @Override public void validate() { } // ✗ COMPILE ERROR — final
@Override
public void process() { } // ✓ allowed
}
Return Types and void #
Methods can return any value including arrays, objects, and generic types — or return nothing (void).
// void — returns no value
public void print(String message) {
System.out.println(message);
// return; — allowed, but optional for void
}
// Primitive
public int add(int a, int b) { return a + b; }
public boolean isEven(int n) { return n % 2 == 0; }
// Object
public String format(double price) { return String.format("$%.2f", price); }
public List<String> getNames() { return new ArrayList<>(nameList); }
// Array
public int[] sort(int[] arr) { Arrays.sort(arr); return arr; }
// Generic type
public <T> List<T> wrap(T item) { return List.of(item); }
Multiple Return Points #
Methods may have more than one return, but they need to be used wisely:
// ANTI-PATTERN: returns scattered through complex logic — hard to trace
public String grade(int score) {
String result = "";
if (score >= 90) {
result = "A";
return result; // returns too early, inconsistent
}
// ... more conditions
return result;
}
// CORRECT: guard clauses at the start (early returns for errors/edge cases)
public String grade(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("Score must be 0-100, got: " + score);
}
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "E";
}
Parameters: Pass-by-Value #
Java always uses pass-by-value — meaning a copy of the value is sent to the method, not the original variable. This is clear for primitive types, but often misunderstood for reference types.
// Primitive: a copy of the value is sent — changes inside the method don't affect the outside
public static void tryChange(int x) {
x = 100; // only changes the local copy
}
int n = 5;
tryChange(n);
System.out.println(n); // still 5 — unchanged
// Reference: a copy of the ADDRESS is sent — you can modify the pointed-to object,
// but you can't replace the reference itself
public static void addItem(List<String> list) {
list.add("new"); // ✓ modifying the object works
list = new ArrayList<>(); // ✗ only changes the local reference copy
}
List<String> list = new ArrayList<>();
list.add("initial");
addItem(list);
System.out.println(list); // [initial, new] — add worked, but reassign didn't
flowchart LR
subgraph "Pass-by-value: Primitive"
A["int n = 5\n(Stack)"] -->|"value copy"| B["parameter x = 5\n(method's local stack)"]
B --> C["x = 100\nlocal only"]
A --> D["n is still 5"]
end
subgraph "Pass-by-value: Reference"
E["List list\n→ address 0x1234"] -->|"address copy"| F["parameter list\n→ address 0x1234"]
F --> G["list.add() → modifies\nthe object at 0x1234 ✓"]
F --> H["list = new ArrayList()\nonly replaces the copy ✗"]
endMethod Overloading #
Overloading allows several methods with the same name as long as their signatures differ — the number, type, or order of parameters differs. The return type alone isn’t enough to distinguish overloads.
public class Calculator {
// Overloading by parameter type
public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
public String add(String a, String b) { return a + b; }
// Overloading by parameter count
public int add(int a, int b) { return a + b; }
public int add(int a, int b, int c) { return a + b + c; }
public int add(int a, int b, int c, int d){ return a + b + c + d; }
}
// The compiler automatically picks the best-matching overload
Calculator calc = new Calculator();
calc.add(1, 2); // calls add(int, int)
calc.add(1.0, 2.0); // calls add(double, double)
calc.add("Hello", " World"); // calls add(String, String)
calc.add(1, 2, 3); // calls add(int, int, int)
Overly aggressive overloading can be confusing. If two overloads do something semantically different, it’s better to give them different names:
// ANTI-PATTERN: confusing overloads — both called "print" but behave differently public void print(String text) { System.out.println(text); } public void print(String text, boolean uppercase) { System.out.println(uppercase ? text.toUpperCase() : text); } // CORRECT: names that reflect the difference public void print(String text) { System.out.println(text); } public void printUppercase(String text) { System.out.println(text.toUpperCase()); }
Varargs (Variable Arguments) #
Varargs allow a method to accept an unspecified number of arguments. Behind the scenes, varargs are a plain array — only the syntax is more concise.
// Syntax: type... name — must be the last parameter
public static int sum(int... numbers) {
int total = 0;
for (int n : numbers) {
total += n;
}
return total;
}
// Calling — from zero to many arguments
sum(); // 0 — numbers = int[]{}
sum(1); // 1
sum(1, 2, 3); // 6
sum(1, 2, 3, 4, 5); // 15
// Varargs with other parameters — varargs must come last
public static String format(String template, Object... args) {
return String.format(template, args);
}
format("Hello %s, age %d", "Budi", 25);
// You can also pass an array directly
int[] arr = {10, 20, 30};
sum(arr); // ✓ an array is directly accepted as varargs
Recursion #
Recursion is a technique where a method calls itself. Every recursive method must have two parts: a base case (stopping condition) and a recursive case (calling itself with a smaller input).
// Factorial: n! = n × (n-1)!
public static long factorial(int n) {
if (n < 0) throw new IllegalArgumentException("n must be >= 0");
if (n == 0 || n == 1) return 1; // base case
return (long) n * factorial(n - 1); // recursive case
}
// Fibonacci
public static int fibonacci(int n) {
if (n <= 1) return n; // base case
return fibonacci(n - 1) + fibonacci(n - 2); // recursive case
}
sequenceDiagram
participant Main
participant F5 as factorial(5)
participant F4 as factorial(4)
participant F3 as factorial(3)
participant F2 as factorial(2)
participant F1 as factorial(1)
Main->>F5: factorial(5)
F5->>F4: factorial(4)
F4->>F3: factorial(3)
F3->>F2: factorial(2)
F2->>F1: factorial(1)
F1-->>F2: return 1
F2-->>F3: return 2
F3-->>F4: return 6
F4-->>F5: return 24
F5-->>Main: return 120Recursion vs Iteration #
Recursion is more expressive for naturally recursive problems (trees, graphs, divide-and-conquer), but it has per-call overhead and risks StackOverflowError for large inputs:
// Recursion — expressive but risky for large n
public static long factorialRecursive(int n) {
if (n <= 1) return 1;
return n * factorialRecursive(n - 1);
}
// Iteration — safer for large inputs
public static long factorialIterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// factorialRecursive(100_000) → StackOverflowError
// factorialIterative(100_000) → runs fine (the result overflows long, but doesn't crash)
Lambdas and Method References #
Since Java 8, functions can be treated as values using lambda expressions — a concise way to create implementations of functional interfaces (interfaces with exactly one abstract method).
import java.util.function.*;
// Lambda: (parameters) -> expression or block
Runnable r = () -> System.out.println("Hello from a lambda");
r.run();
// With parameters
Function<Integer, Integer> square = n -> n * n;
square.apply(5); // 25
// With two parameters
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
add.apply(3, 4); // 7
// Predicate — a boolean condition
Predicate<String> notEmpty = s -> !s.isEmpty();
notEmpty.test(""); // false
notEmpty.test("hello"); // true
// Consumer — does something, returns nothing
Consumer<String> print = s -> System.out.println(s);
print.accept("Hello");
// Supplier — produces a value without input
Supplier<String> greeting = () -> "Good morning";
greeting.get(); // "Good morning"
Method References #
A method reference is a more concise version of a lambda when the lambda merely calls an existing method:
List<String> names = List.of("Andi", "Budi", "Cici");
// Lambda
names.forEach(n -> System.out.println(n));
// Method reference — more concise
names.forEach(System.out::println); // instance method of System.out
// Static method reference
names.stream()
.map(String::toUpperCase) // instance method via a class reference
.forEach(System.out::println);
// Constructor reference
Supplier<ArrayList<String>> creator = ArrayList::new;
ArrayList<String> list = creator.get();
Method Anti-Patterns #
Some patterns to avoid when writing methods:
// ✗ ANTI-PATTERN 1: a method that's too long (> 30 lines)
public void processWholeApp() {
// 200 lines of code doing everything
// validation, conversion, saving, sending emails, updating cache, logging...
}
// ✓ CORRECT: break it into small methods with single responsibilities
public void processOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendConfirmation(order);
updateInventory(order);
}
// ✗ ANTI-PATTERN 2: too many parameters (> 3-4)
public void create(String name, int age, String email, String phone,
String address, String city, String zipCode) { }
// ✓ CORRECT: wrap related parameters in an object
public void create(User user) { }
// or use the Builder pattern
// ✗ ANTI-PATTERN 3: names that don't reflect the behavior
public boolean check(User u) { /* actually updates the database */ }
public void process(Order o) { /* name too generic */ }
// ✓ CORRECT: name = verb + clear context
public boolean isEmailRegistered(String email) { }
public void payOrder(Order order) { }
// ✗ ANTI-PATTERN 4: a method doing more than one thing (violates SRP)
public User loginAndUpdateLastSeen(String email, String pass) {
User u = authenticate(email, pass);
u.setLastSeen(LocalDateTime.now()); // hidden side effect
save(u);
return u;
}
// ✓ CORRECT: separate the responsibilities
public User authenticate(String email, String pass) { ... }
public void updateLastSeen(User u) { ... }
Summary #
- Method anatomy — modifier + return type + name + parameters + throws + body; method names should start with a verb describing what they do.
staticvs instance —staticbelongs to the class and can be called without an object; instance methods can accessthisand instance fields.- Java is always pass-by-value — for primitives, a copy of the value is sent; for references, a copy of the address is sent — you can modify the pointed-to object but can’t replace the original reference.
- Overloading is based on the signature — different count, type, or order of parameters; the return type alone isn’t enough; avoid overloads that do semantically different things.
- Varargs for flexible arguments —
type... namemust be the last parameter; behind the scenes it’s a plain array.- Recursion needs a base case — without a correct base case, unbounded recursion causes
StackOverflowError; use iteration for large inputs.- Lambdas for functional interfaces —
(param) -> expressionconcisely implements one-method interfaces; use method referencesClass::methodwhen the lambda merely forwards to a single method.- Single Responsibility — every method should do exactly one thing; long methods and many parameters are signs the method needs to be split.