Introduction : l'éternel dilemme du développeur Kotlin
Si vous avez déjà écrit du code en Kotlin et avez été confronté à la nécessité de différer l'initialisation d'une propriété, vous vous êtes sûrement demandé : « Que choisir — by lazy ou lateinit ? » Cette question est un véritable succès sur Stack Overflow (des milliers de vues, des dizaines de discussions). Et ce n'est pas sans raison : du bon choix dépend non seulement la lisibilité du code, mais aussi les performances de l'application.
Dans cet article, nous ne nous contenterons pas de répéter la documentation. Nous allons mener une véritable enquête : analyser le fonctionnement interne des deux mécanismes, comparer leur vitesse, leur consommation mémoire et donner des recommandations claires. Et à la fin, nous résumerons avec des actions concrètes. C'est parti !
Qu'est-ce que lateinit et by lazy ? Petit rappel
Pour commencer, rafraîchissons les concepts de base. Les deux approches résolvent le même problème : différer l'initialisation d'une propriété jusqu'à sa première utilisation. Mais elles le font différemment.
lateinit var — une promesse au compilateur
Le mot-clé lateinit dit au compilateur : « Je promets d'initialiser cette propriété avant la première utilisation, pour l'instant, réserve simplement de l'espace pour elle. » Il s'utilise uniquement avec var et seulement pour les propriétés mutables. Idéal pour l'injection de dépendances (Dagger, Hilt, Koin) ou pour les propriétés initialisées dans onCreate d'une Activity/Fragment.
class MyActivity : AppCompatActivity() { lateinit var repository: UserRepository
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) repository = UserRepository() // initialisation }
fun loadUser() { // Si repository n'est pas initialisé — UninitializedPropertyAccessException repository.getUser() }}by lazy — un délégué paresseux
Le délégué lazy est un mécanisme plus flexible. Il crée un objet wrapper qui, lors de la première utilisation, appelle une lambda et mémorise le résultat. Toutes les utilisations suivantes renvoient simplement la valeur sauvegardée. Par défaut, il est thread-safe (synchronisé), mais le mode peut être configuré.
class MyViewModel : ViewModel() { val heavyData: ExpensiveData by lazy { // Ce bloc ne s'exécutera qu'une seule fois, à la première utilisation ExpensiveData() }}Les deux approches résolvent le problème de « l'initialisation paresseuse », mais avec des compromis différents. Analysons ces compromis à la loupe.
Performances : qui est le plus rapide ?
Nous arrivons ici à la partie la plus intéressante — les chiffres. Les performances ne concernent pas seulement la vitesse d'accès à la propriété, mais aussi les frais généraux d'initialisation, la mémoire, la sécurité des threads.
Vitesse du premier accès
lateinit — le champion absolu. Lors du premier accès, le compilateur vérifie simplement que le champ n'est pas null (s'il est nullable, il lève une exception). Pas de lambdas, pas de synchronisation. C'est quasiment gratuit.
by lazy — plus lent. Lors du premier accès, un bloc synchronisé est appelé (si le mode par défaut est LazyThreadSafetyMode.SYNCHRONIZED), la lambda est exécutée, le résultat est mis en cache. Cela prend des microsecondes, mais dans des boucles à forte charge ou dans du code critique, cela peut être perceptible.
Vitesse des accès répétés
Après l'initialisation, les deux approches fonctionnent aussi rapidement. lateinit lit simplement le champ (comme un var normal). by lazy, après la première initialisation, lit également la valeur mise en cache. La différence de vitesse à ce stade tend vers zéro.
Consommation mémoire
Ici, lateinit gagne haut la main. Il ne crée pas d'objet wrapper supplémentaire, contrairement à lazy qui nécessite un objet délégué (environ 16-24 octets supplémentaires selon l'implémentation). Dans la plupart des cas, cette différence est négligeable, mais dans les applications avec des milliers de propriétés paresseuses, elle peut s'accumuler.