Todas as classes que utilizamos no Java, sejam nossas ou de APIs da plataforma, descendem de uma única classe em comum: a classe Object. Ela disponibiliza métodos valiosos que são acessíveis a partir de qualquer outra classe, mesmo que esta não tenha sido declarada como subclasse de Object. Em outras palavras, nunca precisaremos escrever public class ClasseQualquer extends Object para que um objeto de tipo ClasseQualquer possa utilizar os métodos de Object.

Um desses métodos é o toString, que apesar de simples é bastante útil. Ele retorna uma string que representa textualmente o objeto do qual ele fora chamado. A classe Object já possui uma implementação de toString que retorna uma string com o seguinte valor:

getClass().getName() + '@' + Integer.toHexString(hashCode())

Essa string é apenas no nome da classe a qual o objeto pertence, seguido do caractere ‘@’ e do hash code do objeto, representado em base hexadecimal.

A maioria das classes do Java, entretanto, reimplementam toString para que a string retornada seja mais significativa do que a gerada pela implementação padrão de Object. Como exemplo, podemos instanciar um objeto da classe LocalTime, que representa um horário, e imprimir a string retornada pelo seu toString:

import java.time.LocalTime;

public class Programa {
    public static void main(String[] args) {
        LocalTime meioDia = LocalTime.of(12, 0);

        System.out.println(meioDia.toString());
        // imprime "12:00"
    }
}

Perceba que toString, nesse caso, retorna apenas uma representação simples do horário. Já para objetos da classe Point, utilizada pra representar a localização de um ponto num plano cartesiano, toString retorna o nome da classe seguido dos valores das suas variáveis entre colchetes, um padrão seguido também por outras classes do Java:

import java.awt.Point;

public class Programa {
    public static void main(String[] args) {
        Point origem = new Point(0, 0);

        System.out.println(origem.toString());
        // imprime "java.awt.Point[x=0,y=0]"
    }
}

No entanto, qual seria a vantagem de sobrescrever o toString ? Ao invés de criar outro método qualquer que tenha a mesma finalidade de retornar uma string que represente o nosso objeto? Acontece que o toString é invocado automaticamente quando tentamos imprimir um objeto usando métodos como print, println, format, entre outros. Ou, quando concatenamos um objeto com uma string usando o operador “+”.

Como exemplo, podemos declarar, então, a seguinte classe:

import java.time.LocalDate;

public class User {

    final String cpf;
    final LocalDate dataDeNascimento;
    
    public static void main(String[] args) {
        User newUser = new User("012.345.567-89", LocalDate.of(1990, 1, 1));
        System.out.print(newUser);
        // imprime "CPF: 012.345.567-89
                    Data de nascimento(AAA-MM-DD):1990-01-01"
    }
    
    User(String cpf, LocalDate data) {
        this.cpf = cpf;
        dataDeNascimento = data;
    }
    
    public String toString() {
        return String.format("CPF: %s%nData de nascimento(AAA-MM-DD):" + dataDeNascimento, cpf);
    }
}

Perceba que neste código foi possível concatenar o objeto dataDeNascimento ao resto da string que forma o valor retornado pelo toString de User e que conseguimos imprimir este valor apenas usando o próprio objeto newUser com argumento de print.

Sobrescrever o toString, embora pareça trivial, nos permite escrever códigos mais concisos e exibir informações de um objeto de uma determinada classe, aproveitando dos mecanismos de herança e encapsulamento do Java.