Sobrecarga de Métodos: Resolução em Tempo de Compilação e Suas Armadilhas
Objective
Overloading escolhe um método em tempo de compilação, com base no tipo declarado (estático) dos argumentos — não no tipo em runtime deles. Overriding, ao contrário, é resolvido em runtime, com base na classe real do objeto. Confundir os dois é a origem de uma surpresa clássica e reproduzível, e também molda como os parâmetros de um método deveriam ser projetados desde o início: prefira interfaces a classes concretas, e prefira enums a boolean quando o call site ficaria ilegível de outra forma.
Use Cases
- Depurar uma chamada a um método sobrecarregado que "escolhe a versão errada" quando o argumento chega como uma variável tipada como supertipo (
Collection<?>,Object, um número boxed). - Decidir se um novo método deveria ser uma sobrecarga de um nome já existente ou um método com nome totalmente diferente.
- Projetar a assinatura de um método público de API: quantos parâmetros, quais tipos,
booleanvs. enum. - Revisar um PR que sobrecarrega um método com parâmetros
intelong/Integer, e raciocinar sobre quais call sites são de fato seguros.
Deep Dive
Resolução de sobrecarga é estática; resolução de override é dinâmica
javapublic class CollectionClassifier {
public static String classify(Set<?> s) {
return "Set";
}
public static String classify(Collection<?> c) {
return "Unknown Collection";
}
public static void main(String[] args) {
Collection<?>[] collections = {
new HashSet<String>(),
new ArrayList<String>()
};
for (Collection<?> c : collections) {
System.out.println(classify(c)); // both print "Unknown Collection"
}
}
}A variável do loop c é declarada como Collection<?>, e esse tipo declarado é tudo o que o compilador olha ao resolver classify(c). Não importa que o primeiro elemento seja, de fato, um HashSet em runtime — a única sobrecarga que o compilador consegue provar que se aplica a uma expressão tipada Collection<?> é classify(Collection<?>), então é essa que fica gravada no bytecode em ambos os call sites. O resultado: Unknown Collection duas vezes, mesmo com um Set ali, bem na frente, em runtime.
Compare isso com overriding, que resolve exatamente ao contrário — pela classe real do objeto, ignorando o tipo declarado da variável:
javaclass Wine {
String name() { return "wine"; }
}
class SparklingWine extends Wine {
@Override String name() { return "sparkling wine"; }
}
public class Overriding {
public static void main(String[] args) {
Wine[] wines = { new Wine(), new SparklingWine() };
for (Wine wine : wines) {
System.out.println(wine.name()); // "wine", then "sparkling wine"
}
}
}Aqui, toda variável do loop tem o mesmo tipo declarado, Wine, mas as duas chamadas imprimem coisas diferentes — porque name() é sobrescrito (overridden), e métodos sobrescritos despacham pelo tipo em runtime do receiver. Overloading faz o oposto: toda chamada acima compartilha o mesmo comportamento em runtime porque compartilha o mesmo tipo declarado, independentemente do tipo em runtime do argumento.
Se a intenção é realmente ramificar com base no tipo em runtime de um objeto, overloading não entrega isso — uma checagem explícita com instanceof sim:
javapublic static String classify(Collection<?> c) {
return c instanceof Set ? "Set" : "Unknown Collection";
}Evitando a armadilha: não deixe a escolha de overload mudar o comportamento
O bug acima não é realmente sobre Collection — é sobre escrever sobrecargas com o mesmo nome que fazem coisas significativamente diferentes e depois chamá-las por uma variável tipada como um supertipo comum. Duas saídas possíveis:
1. Dar nomes diferentes. ObjectOutputStream não sobrecarrega write para cada tipo primitivo — ele expõe writeBoolean(boolean), writeInt(int), writeLong(long), etc. Ninguém pode ser surpreendido sobre qual método roda, porque o call site já nomeia explicitamente qual é.
2. Se precisarem compartilhar o nome, faça-as se comportarem de forma idêntica. String.contentEquals(StringBuffer) é anterior a CharSequence; quando CharSequence foi adicionada, String ganhou também contentEquals(CharSequence). As duas sobrecargas coexistem com segurança só porque a mais específica repassa para a mais genérica, em vez de fazer algo diferente:
javapublic boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}Um caller que não consegue saber qual sobrecarga disparou ainda assim recebe a mesma resposta de qualquer jeito — então a ambiguidade é inofensiva.
Autoboxing torna essa armadilha mais fácil de cair do que costumava ser, porque int e Integer já não são "radicalmente diferentes" em um call site do jeito que int e String são:
javaList<Integer> a = new ArrayList<>(List.of(10, 20, 30));
a.remove(1); // calls remove(int index) -> removes index 1, a is now [10, 30]
List<Integer> b = new ArrayList<>(List.of(10, 20, 30));
b.remove((Integer) 1); // calls remove(Object o) -> removes the value 1; not present, b unchangedList<E> sobrecarrega remove(int index) e remove(E) (erasure para remove(Object)). Um argumento int resolve para a sobrecarga int sem sequer precisar de autoboxing, então list.remove(1) remove pela posição, não pelo valor — uma fonte comum de confusão quando uma lista contém Integers e o caller esperava "remova esse valor."
Design de assinatura: enum em vez de boolean, interface em vez de classe
Um parâmetro boolean é opaco no call site — quem lê precisa ir procurar o que true significa:
javaThermometer.newInstance(true); // true meaning what, exactly?Um enum de dois elementos se autodocumenta, e deixa espaço para crescer:
javapublic enum TemperatureScale { FAHRENHEIT, CELSIUS }
Thermometer.newInstance(TemperatureScale.CELSIUS); // unambiguousAdicionar KELVIN depois é uma mudança de uma linha no enum; um parâmetro boolean não tem equivalente para um terceiro estado sem quebrar a assinatura do método.
Os tipos de parâmetro merecem o mesmo escrutínio: prefira uma interface a uma classe concreta sempre que o método depender apenas do comportamento da interface.
java// Ties every caller to HashMap specifically
void printAll(HashMap<String, Integer> map) { ... }
// Accepts HashMap, TreeMap, a submap view, or any future Map implementation
void printAll(Map<String, Integer> map) { ... }Declarar o parâmetro como HashMap obriga um caller que tem um TreeMap (ou qualquer outro Map) a copiá-lo para um HashMap só para chamar o método — uma conversão desnecessária e potencialmente cara que a versão tipada com a interface nunca exige.
Trade-offs
- Overloading por tipo declarado é invisível no call site — quem lê não consegue saber qual sobrecarga roda sem conhecer o tipo estático de cada argumento, não apenas o que o objeto de fato é em runtime. Reserve overloading para os casos em que os tipos de parâmetro são radicalmente diferentes (um
inte umaCollectionjamais podem ser confundidos) ou em que toda sobrecarga é garantidamente equivalente em comportamento. - Sobrecargas com a mesma aridade são a forma mais arriscada. Uma regra conservadora e mecânica: nunca exponha duas sobrecargas com o mesmo número de parâmetros a menos que um tipo de parâmetro em cada par seja impossível de converter para o outro.
ArrayList(int)vs.ArrayList(Collection<?>)é segura nesse sentido;remove(int)vs.remove(E)emList<Integer>não é, porqueintconverte paraIntegervia autoboxing.javanew ArrayList<>(List.of(10, 20, 30)).remove(1); // removes index 1 -> [10, 30] new ArrayList<>(List.of(10, 20, 30)).remove(Integer.valueOf(20)); // removes the value 20 -> [10, 30] - Um parâmetro
booleanlê bem para quem escreveu e opaco para todo o resto. O custo de um enum é uma declaração de tipo a mais; o retorno é um call site que não precisa de comentário e uma assinatura que pode crescer uma terceira opção sem quebrar a API. - Um parâmetro tipado como interface não custa nada quando o método só chama métodos da interface, e evita forçar callers a uma implementação específica. O único motivo legítimo para exigir uma classe concreta é quando o método realmente precisa de algo que a interface não expõe (ex.: um método específico da classe) — fora isso, é uma restrição desnecessária.