Java · lesson 17 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Abstraction in Java
Learn how Java's abstract classes and interfaces hide complexity and let you define contracts that decouple callers from implementations.
By SkillByExample · Updated
Abstraction means focusing on what an object does, not how it does it. You define a contract
(the operations available) and hide the implementation behind it, so callers can swap one
implementation for another without changing their own code. Java has two tools for this: abstract classes
and interfaces. Every example is one file run with the JDK 21 source launcher
(java File.java).
Programming against an abstract class
An abstract class can fix the shared workflow in a concrete method and leave the implementation-specific steps abstract.
import java.util.List;
public class Transactions {
public static void main(String[] args) {
DatabaseConnector db = new PostgresConnector("localhost:5432");
db.executeTransaction(List.of("INSERT INTO orders VALUES (1)", "UPDATE stock SET n = n - 1"));
}
}
abstract class DatabaseConnector {
protected final String url;
DatabaseConnector(String url) { this.url = url; }
abstract void connect();
abstract void query(String sql);
abstract void disconnect();
void executeTransaction(List<String> statements) { // same for every database
connect();
System.out.println("BEGIN");
statements.forEach(this::query);
System.out.println("COMMIT");
disconnect();
}
}
class PostgresConnector extends DatabaseConnector {
PostgresConnector(String host) { super("jdbc:postgresql://" + host + "/db"); }
void connect() { System.out.println("Connected to " + url); }
void query(String sql) { System.out.println("[PG] " + sql); }
void disconnect() { System.out.println("Disconnected from PostgreSQL"); }
}
$ java Transactions.java
Connected to jdbc:postgresql://localhost:5432/db
BEGIN
[PG] INSERT INTO orders VALUES (1)
[PG] UPDATE stock SET n = n - 1
COMMIT
Disconnected from PostgreSQL
main declares db as DatabaseConnector, and executeTransaction never mentions Postgres.
Adding a MySQL connector means writing one new subclass and changing only the new expression.
Interfaces: contracts with no state
An interface declares methods without holding instance fields. A class can implement many interfaces, so it can satisfy several unrelated contracts at once.
public class Invoices {
public static void main(String[] args) {
Invoice inv = new Invoice("INV-1", 42.5, "alice");
JsonWritable w = inv;
Auditable a = inv;
System.out.println(w.toJson());
System.out.println("created by " + a.createdBy());
}
}
interface JsonWritable { String toJson(); }
interface Auditable { String createdBy(); }
class Invoice implements JsonWritable, Auditable {
private final String id;
private final double amount;
private final String createdBy;
Invoice(String id, double amount, String createdBy) {
this.id = id; this.amount = amount; this.createdBy = createdBy;
}
public String toJson() { return String.format("{\"id\":\"%s\",\"amount\":%.2f}", id, amount); }
public String createdBy() { return createdBy; }
}
$ java Invoices.java
{"id":"INV-1","amount":42.50}
created by alice
The same object is seen through two narrower types. Code holding a JsonWritable can call
toJson() and nothing else — it cannot reach createdBy() even though the object has it.
Interface methods are implicitly public, which is why Invoice has to declare them public.
Default methods
A default method gives an interface a method body. Implementations only supply the one abstract
method and get the convenience methods built on it for free.
import java.time.LocalTime;
public class Logging {
public static void main(String[] args) {
Logger log = new ConsoleLogger();
log.info("Server started");
log.warn("High memory usage");
}
}
interface Logger {
void log(String message);
default void info(String message) { log("[INFO] " + message); }
default void warn(String message) { log("[WARN] " + message); }
}
class ConsoleLogger implements Logger {
public void log(String message) {
System.out.println(LocalTime.now().withNano(0) + " " + message);
}
}
$ java Logging.java
15:21:14 [INFO] Server started
15:21:14 [WARN] High memory usage
The times come from the clock and will differ on your run. ConsoleLogger wrote one method, yet
info and warn both work, because both defaults route through its log().
When two defaults collide
Defaults bring a rule: if a class inherits the same default method from two interfaces, the compiler refuses to pick one.
public class Clash {
public static void main(String[] args) {
System.out.println(new Duck().describe());
}
}
interface Swimmer { default String describe() { return "swims"; } }
interface Flyer { default String describe() { return "flies"; } }
class Duck implements Swimmer, Flyer { }
$ java Clash.java
Clash.java:10: error: types Swimmer and Flyer are incompatible;
class Duck implements Swimmer, Flyer { }
^
class Duck inherits unrelated defaults for describe() from types Swimmer and Flyer
1 error
error: compilation failed
The fix is to override the method in Duck and choose explicitly with Interface.super:
public class ClashFixed {
public static void main(String[] args) {
System.out.println(new Duck().describe());
}
}
interface Swimmer { default String describe() { return "swims"; } }
interface Flyer { default String describe() { return "flies"; } }
class Duck implements Swimmer, Flyer {
public String describe() {
return Swimmer.super.describe() + " and " + Flyer.super.describe();
}
}
$ java ClashFixed.java
swims and flies
Swimmer.super.describe() calls that interface’s default specifically, so Duck can combine both.
Constants and static methods
Interface fields are implicitly public static final, and interfaces can hold static helper
methods called through the interface name.
public class Geometry {
public static void main(String[] args) {
System.out.println(Circles.UNIT);
System.out.println(Circles.area(5));
Circles.UNIT = "m";
}
}
interface Circles {
String UNIT = "cm";
static double area(double r) { return Math.PI * r * r; }
}
$ java Geometry.java
Geometry.java:5: error: cannot assign a value to static final variable UNIT
Circles.UNIT = "m";
^
1 error
error: compilation failed
UNIT was never declared final, yet the compiler calls it static final: an interface cannot hold
per-instance or mutable state. Remove the assignment and it runs:
public class GeometryFixed {
public static void main(String[] args) {
System.out.println(Circles.UNIT);
System.out.println(Circles.area(5));
}
}
interface Circles {
String UNIT = "cm";
static double area(double r) { return Math.PI * r * r; }
}
$ java GeometryFixed.java
cm
78.53981633974483
Abstract class vs interface
| Abstract class | Interface | |
|---|---|---|
| Instance fields | yes | no (constants only) |
| Constructors | yes | no |
| Method bodies | yes | yes, via default and static |
| How many per class | one (extends) | many (implements) |
| Best for | shared base with state | capability contracts |
Functional interfaces
An interface with exactly one abstract method is a functional interface, so a lambda can implement it. Default methods on it can combine lambdas into bigger ones.
public class Validators {
public static void main(String[] args) {
Validator<String> notBlank = s -> !s.isBlank();
Validator<String> hasAt = s -> s.contains("@");
Validator<String> email = notBlank.and(hasAt);
System.out.println(email.validate("alice@example.com"));
System.out.println(email.validate("not-an-email"));
System.out.println(email.validate(" "));
}
}
@FunctionalInterface
interface Validator<T> {
boolean validate(T value);
default Validator<T> and(Validator<T> other) {
return value -> this.validate(value) && other.validate(value);
}
}
$ java Validators.java
true
false
false
and() returns a new lambda, so email is a validator built from two others. @FunctionalInterface
makes the compiler reject a second abstract method; default methods do not count.
Practice
- Add a
MySQLConnectortoTransactions.javaand run the same transaction through it by changing only the line that createsdb.
Solution
import java.util.List;
public class SwapDb {
public static void main(String[] args) {
DatabaseConnector db = new MySQLConnector("localhost:3306");
db.executeTransaction(List.of("INSERT INTO orders VALUES (1)"));
}
}
abstract class DatabaseConnector {
protected final String url;
DatabaseConnector(String url) { this.url = url; }
abstract void connect();
abstract void query(String sql);
abstract void disconnect();
void executeTransaction(List<String> statements) {
connect();
System.out.println("BEGIN");
statements.forEach(this::query);
System.out.println("COMMIT");
disconnect();
}
}
class MySQLConnector extends DatabaseConnector {
MySQLConnector(String host) { super("jdbc:mysql://" + host + "/db"); }
void connect() { System.out.println("Connected to " + url); }
void query(String sql) { System.out.println("[MySQL] " + sql); }
void disconnect() { System.out.println("Disconnected from MySQL"); }
}
$ java SwapDb.java
Connected to jdbc:mysql://localhost:3306/db
BEGIN
[MySQL] INSERT INTO orders VALUES (1)
COMMIT
Disconnected from MySQL
executeTransaction is unchanged; the BEGIN/COMMIT lines come from the abstract class.
- Add a
debugdefault method toLoggerand a second implementation,PrefixLogger, that prints a fixed prefix instead of the time.
Solution
public class Logging2 {
public static void main(String[] args) {
Logger log = new PrefixLogger("app");
log.debug("cache miss for user:42");
log.info("ready");
}
}
interface Logger {
void log(String message);
default void info(String message) { log("[INFO] " + message); }
default void debug(String message) { log("[DEBUG] " + message); }
}
class PrefixLogger implements Logger {
private final String prefix;
PrefixLogger(String prefix) { this.prefix = prefix; }
public void log(String message) { System.out.println(prefix + ": " + message); }
}
$ java Logging2.java
app: [DEBUG] cache miss for user:42
app: [INFO] ready
The new default needed no change in any implementation; that is why defaults were added in Java 8.
- Write
Validator<Integer>lambdasisPositiveandisEven, and combine them withand(). Test 4, -4 and 3.
Solution
public class IntValidators {
public static void main(String[] args) {
Validator<Integer> isPositive = n -> n > 0;
Validator<Integer> isEven = n -> n % 2 == 0;
Validator<Integer> both = isPositive.and(isEven);
for (int n : new int[] {4, -4, 3}) {
System.out.println(n + " -> " + both.validate(n));
}
}
}
@FunctionalInterface
interface Validator<T> {
boolean validate(T value);
default Validator<T> and(Validator<T> other) {
return value -> this.validate(value) && other.validate(value);
}
}
$ java IntValidators.java
4 -> true
-4 -> false
3 -> false
and() is generic over T, so it works for Integer exactly as it did for String.
- Add a second abstract method to
Validatorwhile keeping@FunctionalInterface. What happens?
Solution
public class NotFunctional {
public static void main(String[] args) {
Validator<String> v = s -> !s.isEmpty();
System.out.println(v.validate("x"));
}
}
@FunctionalInterface
interface Validator<T> {
boolean validate(T value);
String name();
}
$ java NotFunctional.java
NotFunctional.java:3: error: incompatible types: Validator is not a functional interface
Validator<String> v = s -> !s.isEmpty();
^
multiple non-overriding abstract methods found in interface Validator
NotFunctional.java:8: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
Validator is not a functional interface
multiple non-overriding abstract methods found in interface Validator
2 errors
error: compilation failed
A lambda can supply only one method body, so an interface with two abstract methods cannot be a lambda target — and the annotation makes the compiler say so at the interface itself.