Pensez à utiliser des méthodes de fabrique statiques au lieu de constructeurs
Traditionnellement, l'obtention d'une instance d'objet se fait généralement par la méthode de construction, un nouvel objet ; différents nombres de paramètres d'entrée auront des méthodes de construction différentes ;
Par exemple, pour renvoyer une classe de résultat unifiée, la méthode traditionnelle (pseudocode) est la suivante :
//成功
return new Result(200);
//成功,返回信息、对象
return new Result(200,"成功",data);
//失败,返回信息
return new Result(500,"xxx不能为空");
Nous utilisons l'alternative à la méthode de fabrique statique pour réécrire, comme suit :
//成功,无参
return Result.success();
//成功,返回对象
return Result.ofSuccess(data);
//失败,返回信息
return Result.ofFail("xxx不能为空");
L’utilisation de méthodes de fabrique statique présente les principaux avantages suivants :
- Contrairement aux constructeurs portant le même nom, ils peuvent personnaliser les noms de méthodes pour une utilisation facile ;
- Contrairement à la méthode constructeur qui appelle new un nouvel objet à chaque fois, ils n'ont pas besoin de créer un nouvel objet à chaque fois ; (mode poids mouche ou mode singleton)
- Contrairement aux constructeurs, ils peuvent renvoyer le type de retour défini et ses sous-types ;
- La classe de l'objet renvoyé peut être différente selon les paramètres d'entrée ; (Orienté programmation abstraite)
- Lors de l'écriture d'une classe contenant cette méthode, la classe qui renvoie l'objet n'a pas besoin d'exister ; (pour la programmation abstraite, des classes dérivées peuvent être renvoyées)
Les principaux inconvénients des méthodes de fabrique statique sont :
-
Parce qu'il s'agit d'une classe statique et orientée vers la programmation abstraite, il n'est pas facile d'instancier des sous-classes.
-
En raison du nom de méthode personnalisé, il est difficile pour les programmeurs de la trouver s'il n'y a pas de documentation ou de code source.
Utilisez le modèle Builder lorsqu'il y a trop de paramètres de constructeur
Par exemple, il existe actuellement une classe d'utilisateurs avec plusieurs attributs :
public class User {
private String name;
private String nickname;
private int sex;
private int age;
private String phone;
private String mail;
private String address;
private int height;
private int weight;
//构造方法
public User(String name, String nickname, int sex, int age) {
this.name = name;
this.nickname = nickname;
this.sex = sex;
this.age = age;
}
//getter和setter方法
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
...
La manière de créer des objets selon la méthode traditionnelle est la suivante :
//1.可选参数,构造方法实例化
User user = new User("张天空", "张三", 1, 20);
//2.set方法复制实例化
User user2 = new User();
user2.setName("张天空");
user2.setNickname("张三");
user2.setSex(1);
user2.setAge(20);
user2.setPhone("14785236915");
user2.setHeight(175);
user2.setWeight(157);
L'inconvénient de la méthode de construction est qu'elle n'est pas pratique à mettre à l'échelle face à plusieurs paramètres optionnels ; il est nécessaire de définir une méthode de construction correspondante pour chaque situation ;
Méthode JavaBeans (méthode setter), l'affectation peut être longue ; un inconvénient plus grave est que, puisque la méthode d'affectation de construction peut être divisée en plusieurs appels, le JavaBean peut être dans un état incohérent pendant le processus de construction ; (par exemple, le JavaBean objet en tant que paramètres, lorsqu'ils sont passés par référence, différentes méthodes d'affectation d'ensemble peuvent conduire à une incohérence JavaBean)
L'avantage du modèle Builder par rapport à la méthode de construction est qu'il peut avoir plusieurs paramètres variables et que la construction est plus flexible ;
Utilisez des constructeurs privés ou des énumérations pour implémenter des singletons
// Singleton.java
public enum Singleton {
INSTANCE;
public void testMethod() {
System.out.println("执行了单例类的方法");
}
}
// Test.java
public class Test {
public static void main(String[] args) {
//演示如何使用枚举写法的单例类
Singleton.INSTANCE.testMethod();
System.out.println(Singleton.INSTANCE);
}
}
//输出:
执行了单例类的方法
INSTANCE
Les singletons d'implémentation Enum sont similaires aux méthodes de propriété publique, mais sont plus concis, fournissent un mécanisme de sérialisation gratuit et offrent de solides garanties contre les instanciations multiples, même dans le cas d'attaques complexes de sérialisation ou de réflexion .
Cette approche peut sembler un peu contre nature, mais une classe d'énumération à un seul élément est souvent le meilleur moyen d'implémenter un singleton. Notez que cette méthode ne peut pas être utilisée si le singleton doit hériter d'une classe parent autre qu'Enum (bien qu'il soit possible de déclarer un Enum pour implémenter l'interface) .
Lien de référence : https://juejin.cn/post/7229660119658512441
Utilisez des constructeurs privés pour éviter l'instanciation
Parfois, vous souhaiterez écrire une classe, qui est simplement un ensemble de méthodes et de propriétés statiques ; (comme une classe utilitaire ou une classe constante).
Ces classes utilitaires ne sont pas conçues pour être instanciées, elles peuvent donc être désinstanciées en incluant un constructeur privé.
Utilisez l'injection de dépendances au lieu des ressources de câblage
De nombreuses classes dépendent d'une ou plusieurs ressources sous-jacentes ; les ressources dites de lien dur doivent définir les ressources dépendantes comme statiques ou singletons ; de tels liens durs seront peu pratiques à développer et pas assez flexibles ; ils peuvent être remplacés par une injection de dépendance ; Les classes utilitaires statiques et les singletons ne conviennent pas aux classes dont le comportement est paramétré par les ressources sous-jacentes.
Méthode de ressource de lien physique :
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker() {
} // Noninstantiable
public static boolean isValid(String word) {
... }
public static List<String> suggestions(String typo) {
... }
}
Méthode d'injection de dépendances :
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
public boolean isValid(String word) {
... }
public List<String> suggestions(String typo) {
... }
}
Évitez de créer des objets inutiles
Cette entrée ne doit pas être interprétée à tort comme impliquant que la création d'objets est coûteuse et que la création d'objets doit être évitée. En revanche, il est très peu coûteux de créer et de recycler de petits objets à l'aide de constructeurs, qui effectuent très peu de travail explicite, en particulier sur les implémentations JVM modernes. C'est généralement une bonne chose de créer des objets supplémentaires pour améliorer la clarté, la simplicité ou les fonctionnalités de votre programme.
À l'inverse, c'est une mauvaise idée d'éviter la création d'objets en conservant votre propre pool d'objets, à moins que les objets du pool ne soient très lourds . Un exemple typique de pool d’objets est une connexion à une base de données. Le coût d'établissement d'une connexion est très élevé, il est donc logique de réutiliser ces objets
Éliminer les références d'objets expirées
如果一个栈增长后收缩,那么从栈弹出的对象不会被垃圾收集,即使使用栈的程序 不再引用这些对象。 这是因为栈维护对这些对象的过期引用( obsolete references)。 过期引用简单来说就是永远不 会解除的引用。 在这种情况下,元素数组“活动部分(active portion)”之外的任何引用都是过期的。 活动部分是由 索引下标小于 size 的元素组成
垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。 如果无 意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。 即使只有少 数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。
这类问题的解决方法很简单:一旦对象引用过期,将它们设置为 null;
当程序员第一次被这个问题困扰时,他们可能会在程序结束后立即清空所有对象引用。这既不是必要的,也不是 可取的;它不必要地搞乱了程序。清空对象引用应该是例外而不是规范。消除过期引用的好方法是让包含引用的变 量超出范围。如果在近的作用域范围内定义每个变量 (条目 57),这种自然就会出现这种情况
避免使用Finilizer和Cleaner机制
Le mécanisme du Finalizer est imprévisible, souvent dangereux et souvent inutile . Leur utilisation peut entraîner un comportement erratique, de mauvaises performances et des problèmes de portabilité. Le mécanisme Finalizer a quelques utilisations spéciales, que nous aborderons plus tard dans cet article, mais elles doivent généralement être évitées. Depuis Java 9, le mécanisme Finalizer est obsolète mais est toujours utilisé par les bibliothèques de classes Java. Le mécanisme Cleaner de Java 9 remplace le mécanisme Finalizer. Le mécanisme Cleaner n'est pas aussi dangereux que le mécanisme Finalizer, mais il reste imprévisible, lent et souvent inutile.
Un inconvénient des mécanismes Finalizer et Cleaner est qu'il n'est pas garanti qu'ils s'exécutent en temps opportun [JLS, 12.6]. Le temps entre le moment où un objet devient inaccessible et le moment où les mécanismes Finalizer et Cleaner commencent à s'exécuter est arbitrairement long. Cela signifie que vous ne devez jamais faire quoi que ce soit de urgent avec les mécanismes Finalizer et Cleaner .
L'instruction try-with-resources remplace l'instruction try-finally
Lorsque Java 7 a introduit l'instruction try-with-resources, tous ces problèmes ont été résolus en même temps [JLS, 14.20.3]. Pour utiliser cette construction, la ressource doit implémenter l'interface AutoCloseable , qui consiste en une fermeture qui renvoie void. De nombreuses classes et interfaces dans Java et dans les bibliothèques tierces implémentent ou héritent désormais de l'interface AutoCloseable . Si vous écrivez une classe qui représente une ressource qui doit être fermée, cette classe doit également implémenter l'interface AutoCloseable.
Lorsque vous traitez des ressources qui doivent être fermées, utilisez l'instruction try-with-resources au lieu de l'instruction try-finally . Le code généré est plus propre et plus clair, et les exceptions générées sont plus utiles. L'instruction try-with-resources facilite et sans erreur l'écriture de code qui doit fermer des ressources, ce qui est pratiquement impossible avec l'instruction try-finally.
Suivez les conventions courantes lors du remplacement de la méthode égale
Bien que Object soit une classe concrète, elle est principalement conçue pour l'héritage . Toutes ses méthodes non finales (equals, hashCode, toString, clone et finalize) ont des contrats généraux clairs car elles sont conçues pour être remplacées par des sous-classes. Toute classe est obligée de remplacer ces méthodes pour se conformer à leur contrat général ; ne pas le faire empêchera les autres classes qui s'appuient sur la convention (telles que HashMap et HashSet) de fonctionner correctement avec cette classe .
Quand devez-vous remplacer la méthode égale ? Si une classe contient un concept d’égalité logique distinct de l’identité d’objet et que la classe parent n’a pas remplacé la méthode equals. Ceci est généralement utilisé dans le cas de classes de valeurs. Une classe de valeur est simplement une classe qui représente une valeur, telle qu'une classe Integer ou String. Les programmeurs utilisent la méthode égale pour comparer les références à des objets de valeur, dans l'espoir de déterminer si elles sont logiquement égales, plutôt que de référencer le même objet. Le remplacement de la méthode égale peut non seulement répondre aux attentes du programmeur, mais il prend également en charge le remplacement des instances égales en tant que clés de Map ou éléments de Set pour répondre aux attentes et au comportement souhaité.
Lorsque vous remplacez la méthode equals, vous devez respecter ses conventions générales. La spécification de Object est la suivante : La méthode equals implémente une relation d'équivalence. Il possède les propriétés suivantes :
- Réflexivité : x.equals(x) doit renvoyer true pour toute référence non nulle x
- Symétrie : pour toute référence non nulle x et y, x.equals(y) doit renvoyer true si et seulement si y.equals(x) renvoie true
- Transitivité : pour toute référence non nulle x, y, z, si x.equals(y) renvoie vrai et y.equals(z) renvoie vrai, alors x.equals(z) doit renvoyer vrai
- Cohérence : pour toutes les références non nulles x et y, plusieurs appels à x.equals(y) doivent toujours renvoyer vrai ou toujours renvoyer faux si les informations utilisées dans la comparaison égale ne sont pas modifiées.
- Pour toute référence x non nulle, x.equals(null) doit renvoyer false
En résumé, voici une recette pour écrire une méthode d'égalité de haute qualité :
- Utilisez l'opérateur == pour vérifier si l'argument est une référence à l'objet . Si oui, retournez vrai. Il ne s'agit que d'une optimisation des performances, mais si cette comparaison risque de coûter cher, cela en vaut la peine.
- Utilisez l'opérateur instanceof pour vérifier si les paramètres ont le bon type . Sinon, retournez false. Habituellement, le type correct est la classe dans laquelle se trouve la méthode égale. Parfois, la classe est modifiée pour implémenter certaines interfaces. Si une classe implémente une interface qui améliore la convention égale pour permettre la comparaison des classes qui implémentent l'interface, utilisez l'interface. Les interfaces de collection telles que Set, List, Map et Map.Entry disposent de cette fonctionnalité. 3. Convertissez les paramètres au type correct. L'opération de conversion étant déjà gérée dans instanceof, elle réussira certainement.
- Pour chaque propriété "importante" de la classe, vérifiez si la propriété du paramètre correspond à la propriété correspondante de l'objet . Renvoie vrai si tous ces tests réussissent, faux sinon. Si le type à l'étape 2 est une interface, alors les propriétés du paramètre doivent être accessibles via des méthodes d'interface ; si le type est une classe, les propriétés sont accessibles directement, en fonction des autorisations d'accès de la propriété.
En bref, ne remplacez pas la méthode equals sauf si vous y êtes obligé : dans de nombreux cas, l'implémentation héritée d'Object est exactement ce que vous souhaitez. Si vous remplacez la méthode égale, assurez-vous de comparer toutes les propriétés importantes de la classe et faites-le de manière à protéger les cinq dispositions du contrat égal ci-dessus.
Lorsque vous remplacez la méthode equals, vous devez également remplacer la méthode hashcode.
Dans chaque classe, lorsque vous remplacez la méthode equals, assurez-vous de remplacer la méthode hashcode . Si vous ne le faites pas, votre classe viole la convention générale de hashCode, ce qui l'empêche de fonctionner correctement avec des collections comme HashMap et HashSet.
Selon la spécification Object, les conventions spécifiques suivantes sont définies :
- Lorsque la méthode hashCode est appelée à plusieurs reprises sur un objet lors de l'exécution d'une application, elle doit toujours renvoyer la même valeur si aucune information n'est modifiée dans la comparaison de la méthode equals . La valeur renvoyée par chaque exécution d'une application à une autre peut être incohérente
- Si deux objets sont égaux selon la méthode equals(Object), alors l'appel de hashCode sur les deux objets doit produire le même entier.
- Si deux objets ne sont pas comparables selon la méthode equals(Object), il n'est pas nécessaire que l'appel de hashCode sur chaque objet produise un résultat différent. Cependant, les programmeurs doivent être conscients que générer des résultats différents pour des objets inégaux peut améliorer les performances des tables de hachage.
Lorsque hashCode ne peut pas être remplacé, la deuxième clause clé violée est : les objets égaux doivent avoir des codes de hachage égaux
@Override
public int hashCode() {
return 42; }
这是合法的,因为它确保了相等的对象具有相同的哈希码。这很糟糕,因为它确保了每个对象都有相同的哈希 码。因此,每个对象哈希到同一个桶中,哈希表退化为链表。应该在线性时间内运行的程序,运行时间变成了平方级 别。对于数据很大的哈希表而言,会影响到能够正常工作。
**一个好的 hash 方法趋向于为不相等的实例生成不相等的哈希码**。这也正是 hashCode 约定中第三条的表达。理 想情况下,hash 方法为集合中不相等的实例均地分配 int 范围内的哈希码
En bref, la méthode hashCode doit être réécrite à chaque fois que la méthode equals est remplacée , sinon le programme ne fonctionnera pas correctement. Votre méthode hashCode doit suivre les conventions générales spécifiées par la classe Object et doit effectuer un travail raisonnable en attribuant des codes de hachage inégaux à des instances inégales.
Remplacez toujours la méthode toString
Bien que la classe Object fournisse une implémentation de la méthode toString, la chaîne qu'elle renvoie ne correspond généralement pas à ce que les utilisateurs de votre classe souhaitent voir. Il se compose du nom de la classe suivi d'un signe "arobase" (@) et de la représentation hexadécimale non signée du code de hachage, par exemple User@163b91.
La convention générale de toString exige que la chaîne renvoyée soit « une représentation concise mais informative et facilement lisible par les humains ».
Bien que cela ne soit pas aussi important que de suivre les conventions equals et hashCode, fournir une bonne implémentation de toString rend votre classe plus facile à utiliser et les systèmes qui l'utilisent plus faciles à déboguer. La méthode toString est automatiquement appelée lorsque l'objet est passé à println, printf, opérateur de concaténation de chaînes ou assertion, ou imprimé par le débogueur.
Remplacez soigneusement la méthode de clonage
Supposons que vous souhaitiez implémenter l'interface Cloneable dans une classe dont la classe parent fournit une méthode de clonage efficace. Appelez d’abord super.clone. L'objet résultant sera une réplique entièrement fonctionnelle de l'original. Toutes les propriétés déclarées dans votre classe auront la même valeur que la propriété d'origine. Si chaque propriété contient une valeur primitive ou une référence à un objet immuable, l'objet renvoyé peut être exactement ce dont vous avez besoin, auquel cas aucun traitement supplémentaire n'est requis.
Compte tenu de tous les problèmes associés à l'interface Cloneable, les nouvelles interfaces ne devraient pas en hériter et les nouvelles classes extensibles ne devraient pas l'implémenter. Bien qu'il n'y ait aucun mal à implémenter l'interface Cloneable pour les classes finales, elle doit être considérée dans une perspective d'optimisation des performances et n'est justifiée que dans de rares cas (élément 67). Habituellement, la fonctionnalité de copie est mieux assurée par un constructeur ou une usine. L'exception évidente à cette règle concerne les tableaux, qui peuvent être copiés à l'aide de la méthode clone.
Envisagez d'implémenter l'interface Comparable
Parfois, vous pouvez constater que les méthodes compareTo ou compare reposent sur la différence entre deux valeurs, qui est négative si la première valeur est inférieure à la deuxième valeur ; zéro si les deux valeurssont égales et zéro si la première. la valeur est égale. Si la valeur est supérieure à , c'est une valeur positive. Voici un exemple:
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return o1.hashCode() - o2.hashCode();
}
};
N'utilisez pas cette technique ! Cela peut entraîner des dangers de débordement d'entiers de grande longueur et de distorsion arithmétique à virgule flottante IEEE 754 [JLS 15.20.1, 15.21.1]. De plus, il est peu probable que la méthode résultante soit significativement plus rapide que celle écrite à l’aide des techniques ci-dessus. Utilisez la méthode de comparaison statique :
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
}
};
Ou utilisez la méthode de construction Comparator :
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(o -> o.hashCode());
En résumé, chaque fois que vous implémentez une classe de valeurs avec un ordre raisonnable, vous devez demander à cette classe d'implémenter l'interface Comparable afin que ses instances puissent être facilement triées, recherchées et utilisées dans des collections basées sur des comparaisons. Lorsque vous comparez les valeurs de champ dans les implémentations de la méthode compareTo, évitez d'utiliser les opérateurs "<" et ">". Utilisez plutôt la méthode de comparaison statique dans la classe wrapper ou la méthode build dans l'interface Comparator.
Rendre les classes et les membres moins accessibles
Un composant bien conçu cache tous les détails de son implémentation, séparant clairement son API de son implémentation . Les composants communiquent alors uniquement via leurs API et ne connaissent rien du fonctionnement interne de chacun. Ce concept, connu sous le nom de masquage ou d'encapsulation d'informations, est un principe fondamental de la conception de logiciels (loi de Demit, principe du moins connu).
La dissimulation d’informations est importante pour de nombreuses raisons, dont la plupart proviennent du fait qu’elle sépare les composants qui composent un système, leur permettant ainsi d’être développés, testés, optimisés, utilisés, compris et modifiés de manière indépendante. Cela accélère le développement du système car les composants peuvent être développés en parallèle . Cela allège le fardeau de la maintenance car les composants peuvent être compris, débogués ou remplacés plus rapidement sans craindre d'endommager d'autres composants.
Java fournit de nombreux mécanismes pour faciliter la dissimulation des informations. Le mécanisme de contrôle d'accès [JLS, 6.6] spécifie l'accessibilité des classes, des interfaces et des membres. L'accessibilité d'une entité dépend de l'endroit où elle est déclarée et des modificateurs d'accès (privé, protégé et public) présents dans la déclaration. Une utilisation appropriée de ces modificateurs est essentielle à la dissimulation des informations.
La règle de base est simple : rendre chaque classe ou membre aussi inaccessible que possible. En d’autres termes, utilisez le niveau d’accès le plus bas possible , cohérent avec les fonctionnalités du logiciel que vous écrivez.
Utilisez des méthodes d'accès au lieu des propriétés publiques dans les classes publiques
Pour les classes publiques, il est correct de s'en tenir à l'orientation objet : si une classe est accessible en dehors de son package, fournissez des méthodes d'accès pour préserver la flexibilité de modifier la représentation interne de la classe . Si une classe publique expose ses propriétés de données, il est pratiquement impossible de modifier sa représentation ultérieurement car le code client peut être réparti à plusieurs endroits.
Cependant, si une classe est privée au niveau du package ou est une classe interne privée, alors il n'y a rien de mal en soi à exposer ses propriétés de données - en supposant qu'elles fournissent une description suffisante de l'abstraction fournie par la classe.
En bref, les classes publiques ne doivent pas exposer de propriétés mutables. Le préjudice causé par l’exposition du public à des propriétés immuables reste problématique, mais moins nocif. Cependant, il arrive parfois qu'une classe interne privée ou privée au niveau du package soit nécessaire pour exposer les propriétés, que cette classe soit modifiable ou non.
Minimiser la variabilité
Une classe immuable . Toutes les informations contenues dans chaque instance sont fixes pour la durée de vie de l'objet, donc aucun changement ne sera observé. La bibliothèque de classes de la plateforme Java contient de nombreuses classes immuables, notamment la classe String, les classes wrapper de type de base et les classes BigInteger et BigDecimal. Il existe de nombreuses bonnes raisons : les classes immuables sont plus faciles à concevoir, à implémenter et à utiliser que les classes mutables. Ils sont moins sujets aux erreurs et plus sécurisés.
Pour rendre une classe immuable, suivez ces cinq règles :
- Ne fournissez pas de méthodes pour modifier l'état de l'objet
- Assurez-vous que cette classe ne peut pas être héritée . Cela empêche les sous-classes imprudentes ou malveillantes de supposer que l'état de l'objet a changé, brisant ainsi le comportement immuable de la classe. Empêcher le sous-classement se fait généralement en rendant une classe finale, mais nous discuterons d'une autre méthode plus tard.
- Définissez toutes les propriétés sur final . Appliquez-les via le système et communiquez clairement vos intentions. De plus, si une référence à une instance nouvellement créée est transmise d'un thread à un autre sans synchronisation, un comportement correct doit être garanti, comme décrit dans le modèle de mémoire [JLS, 17.5 ; Goetz06,16].
- Définissez toutes les propriétés sur private . Cela empêche les clients d'accéder aux objets mutables référencés par les propriétés et de modifier directement ces objets. Bien qu'il soit techniquement autorisé pour une classe immuable d'avoir une propriété finale publique contenant une valeur numérique de type de base ou une référence à un objet immuable, cela n'est pas recommandé car cela ne permet pas à la représentation interne de changer dans une version ultérieure.
- Garantissez un accès mutuellement exclusif à tous les composants mutables . Si votre classe possède des propriétés faisant référence à des objets mutables, assurez-vous que les clients de la classe ne peuvent pas obtenir de références à ces objets. N'initialisez jamais une telle propriété avec une référence d'objet fournie par le client et ne renvoyez jamais la propriété à partir d'une méthode d'accès. Copie défensive dans les constructeurs, les accesseurs et les méthodes readObject (élément 88)
Les objets immuables sont simples. Un objet immuable peut être complètement dans un seul état, c'est-à-dire l'état dans lequel il a été créé.
Les objets immuables sont intrinsèquement thread-safe ; ils ne nécessitent pas de synchronisation . Ils ne sont pas corrompus lorsqu’ils sont accédés simultanément par plusieurs threads. Il s’agit d’un moyen simple d’assurer la sécurité des threads. Étant donné qu'aucun thread ne peut observer l'effet d'un autre thread sur un objet immuable, les objets immuables peuvent être librement partagés .
Non seulement les objets immuables peuvent être partagés, mais également les informations internes peuvent être partagées ;
Les objets immuables fournissent d'excellents éléments de base pour d'autres objets , qu'ils soient mutables ou immuables. Il est beaucoup plus facile de conserver les invariants d'un composant complexe si vous savez que ses objets internes ne changeront pas.
Les objets immuables fournissent un mécanisme de défaillance atomique gratuit . Leur état ne change jamais, les incohérences temporaires sont donc impossibles
Le principal inconvénient des classes immuables est qu'un objet distinct est requis pour chaque valeur différente .
Dans l’ensemble, n’écrivez jamais une méthode get pour chaque propriété, puis écrivez une méthode set correspondante. Les classes doivent être immuables, sauf s'il existe une bonne raison de les rendre mutables . Les classes immuables offrent de nombreux avantages, le seul inconvénient est que des problèmes de performances peuvent survenir dans certains cas. Vous devez toujours utiliser des objets de valeur plus petite et les rendre immuables.
Pour certaines classes, l'immuabilité n'est pas pratique. Si une classe ne peut pas être conçue pour être immuable, alors sa mutabilité doit être limitée autant que possible . La réduction du nombre d'états dans lesquels un objet peut exister facilite son analyse et réduit le risque d'erreurs. Par conséquent, chaque propriété doit être définie sur final, sauf s'il existe une bonne raison de définir la propriété sur non final. En combinant les conseils de ce point avec ceux du point 15, votre inclination naturelle est de déclarer chaque propriété comme étant privée définitive, à moins qu'il n'y ait une bonne raison de ne pas le faire .
La composition vaut mieux que l’héritage
Contrairement à l'invocation de méthode, l'héritage interrompt l'encapsulation . En d’autres termes, une sous-classe s’appuie sur les détails d’implémentation de sa classe parent pour garantir son bon fonctionnement. L'implémentation de la classe parent peut continuer à changer depuis la version finale, et si tel est le cas, la classe enfant peut être interrompue même si rien dans son code n'a changé. Par conséquent, une sous-classe doit être mise à jour et modifiée avec sa superclasse, à moins que l'auteur de la superclasse ne l'ait spécifiquement conçue à des fins d'héritage et n'ait documenté les instructions.
Ces deux problèmes proviennent de méthodes primordiales. Vous pensez peut-être qu'il est prudent d'hériter d'une classe si vous ajoutez simplement de nouvelles méthodes et ne remplacez pas les méthodes existantes. Si cette extension est plus sécurisée, elle n’est pas sans risques. Si la classe parent ajoute une nouvelle méthode dans une version ultérieure et que vous donnez malheureusement à la sous-classe une méthode avec la même signature et un type de retour différent, alors votre sous-classe ne parviendra pas à compiler . Si vous avez déjà fourni à une classe enfant une méthode qui a la même signature et le même type de retour que la nouvelle méthode de classe parent, vous la remplacez maintenant et rencontrerez donc les problèmes décrits précédemment. De plus, on peut se demander si votre méthode remplira le contrat de la nouvelle méthode de classe parent, puisque ce contrat n'a pas encore été écrit lorsque vous écrivez la méthode de sous-classe.
Heureusement, il existe un moyen d’éviter tous les problèmes ci-dessus. Au lieu d'hériter d'une classe existante, ajoutez une propriété privée à votre nouvelle classe qui est une référence d'instance de la classe existante. Cette conception est appelée composition car la classe existante devient le composant de la nouvelle classe . Chaque méthode d'instance de la nouvelle classe appelle la méthode correspondante sur l'instance contenant de la classe existante et renvoie le résultat. C'est ce qu'on appelle le transfert, et les méthodes de la nouvelle classe sont appelées méthodes de transfert.
En résumé, l’héritage est puissant, mais il pose problème car il viole l’encapsulation. Cela ne s'applique que s'il existe une véritable relation de sous-type entre la classe enfant et la classe parent . Néanmoins, l’héritage peut conduire à une fragilité si la classe enfant ne se trouve pas dans le même package que la classe parent et si la classe parent n’est pas conçue pour l’héritage. Pour éviter cette fragilité, utilisez la composition et le transfert au lieu de l'héritage, surtout s'il existe une interface appropriée pour implémenter la classe wrapper. Les classes wrapper sont non seulement plus robustes que les sous-classes, elles sont également plus puissantes.
Si l’héritage est utilisé, concevez-le et documentez-le.
Alors, que signifie concevoir et documenter une classe pour l'héritage ?
Premièrement, la classe doit décrire avec précision l’impact du remplacement de cette méthode. En d’autres termes, la classe doit documenter l’auto-utilisation de la méthode substituable. Pour chaque méthode publique ou protégée, la documentation doit indiquer quelles méthodes remplacées la méthode appelle, dans quel ordre et comment les résultats de chaque appel affectent le traitement ultérieur. (Les méthodes substituables font ici référence aux méthodes modifiées non finales, qu'elles soient publiques ou protégées.) Plus généralement, une classe doit documenter toute situation dans laquelle une méthode substituable peut être appelée.
Alors, lorsque vous concevez une classe héritée, comment décidez-vous quels membres protégés exposer ? Malheureusement, il n’existe pas de solution miracle. Le mieux que vous puissiez faire est de réfléchir sérieusement, de faire de bons tests, puis de les tester en écrivant des sous-classes. Les membres protégés doivent être exposés le moins possible car chaque membre représente un engagement sur les détails de mise en œuvre.
La seule façon de tester . Si vous omettez un membre protégé critique, essayer d'écrire une sous-classe rendra l'omission douloureusement évidente. À l’inverse, si vous écrivez plusieurs sous-classes et qu’aucune d’entre elles n’utilise de membre protégé, vous devez la rendre privée.
Les constructeurs ne doivent pas appeler de méthodes substituables directement ou indirectement . La violation de cette règle entraînera l'échec du programme. Le constructeur de la classe parent s'exécute avant le constructeur de la classe enfant, de sorte que la méthode remplacée dans la classe enfant est appelée avant l'exécution du constructeur de la classe enfant. Si la méthode remplacée repose sur une initialisation effectuée par le constructeur de sous-classe, cette méthode ne fonctionnera pas comme prévu.
Un bon moyen de résoudre ce problème consiste à désactiver le sous-classement dans les classes qui n'ont pas de conception ni de documentation indiquant que vous souhaitez pouvoir sous-classer en toute sécurité. Il existe deux manières de désactiver le sous-classement. Le plus simple des deux est de déclarer la classe finale. Une autre approche consiste à rendre tous les constructeurs privés ou privés au niveau du package, et à ajouter des usines statiques publiques à la place des constructeurs .
Les interfaces sont meilleures que les classes abstraites
Java dispose de deux mécanismes pour définir des types permettant plusieurs implémentations : les interfaces et les classes abstraites . Depuis que les méthodes d'interface par défaut ont été introduites dans Java 8, les deux mécanismes permettent de fournir des implémentations pour certaines méthodes d'instance. Une différence majeure est que pour implémenter un type défini par une classe abstraite, la classe doit être une sous-classe de la classe abstraite.
Les interfaces sont idéales pour définir des mixins. De manière générale, un mixin est une classe qui, en plus de son « type principal », peut également être déclarée pour fournir un comportement facultatif. Par exemple, Comparable est une interface de type qui permet à une classe de déclarer que ses instances sont ordonnées par rapport à d'autres objets mutuellement comparables. Une telle interface est appelée un type car elle permet de « mélanger » des fonctionnalités facultatives dans la fonctionnalité principale du type. Les classes abstraites ne peuvent pas être utilisées pour définir des classes mixin car elles ne peuvent pas être chargées dans des classes existantes : une classe ne peut pas avoir plus d'une classe parent et il n'y a pas d'endroit raisonnable dans la hiérarchie des classes pour insérer un type.
Les interfaces permettent la construction de cadres non hiérarchiques. Les hiérarchies de types sont idéales pour organiser certaines choses, mais d'autres ne rentrent pas parfaitement dans une hiérarchie stricte.
Cependant, vous pouvez combiner les avantages des interfaces et des classes abstraites en fournissant une classe d'implémentation squelettique abstraite à utiliser avec l'interface . L'interface définit le type et peut fournir certaines méthodes par défaut, tandis que la classe d'implémentation squelette implémente les méthodes d'interface non primitives restantes en plus des méthodes d'interface d'origine. L'héritage de l'implémentation du squelette nécessite la majeure partie du travail d'implémentation d'une interface. Il s'agit du modèle de conception de la méthode modèle. Par exemple, Collections Framework fournit une implémentation de cadre pour accompagner chacune des principales interfaces de collection : AbstractCollection, AbstractSet, AbstractList et AbstractMap.
En résumé, une interface est généralement le meilleur moyen de définir un type permettant plusieurs implémentations . Si vous exportez une interface importante, vous devriez sérieusement envisager de fournir une classe d’implémentation squelette. Dans la mesure du possible, une implémentation squelette doit être fournie via une méthode par défaut sur l'interface afin qu'elle soit disponible pour tous les implémenteurs de l'interface. Autrement dit, les restrictions sur les interfaces nécessitent souvent que les classes d'implémentation squelettes prennent la forme de classes abstraites .
Concevoir des interfaces pour les générations futures
Avant Java 8, il n'était pas possible d'ajouter des méthodes à une interface sans casser l'implémentation existante. Si une nouvelle méthode est ajoutée à une interface, l’implémentation existante manquera souvent la méthode, provoquant une erreur de compilation. Dans Java 8, la construction de méthode par défaut a été ajoutée pour permettre d'ajouter des méthodes aux interfaces existantes . Mais ajouter de nouvelles méthodes aux interfaces existantes comporte de nombreux risques.
La déclaration d' une méthode par défaut . Bien que l'ajout de méthodes par défaut dans Java ajoute des méthodes aux interfaces existantes, rien ne garantit que ces méthodes seront disponibles dans toutes les implémentations existantes. Les méthodes par défaut sont « injectées » dans l’implémentation existante à l’insu ou sans le consentement de la classe implémentante. Avant Java 8, ces implémentations étaient écrites à l'aide de l'interface par défaut, qui n'avait jamais reçu de nouvelles méthodes.
Il est donc très important de tester chaque nouvelle interface avant de la publier. Vous devez au moins préparer trois implémentations différentes. Il est tout aussi important d'écrire plusieurs programmes clients qui utilisent des instances de chaque nouvelle interface pour effectuer diverses tâches. Cela contribuera grandement à garantir que chaque interface réponde à toutes les utilisations prévues. Ces étapes vous permettront de découvrir les failles de votre interface avant la sortie, tout en les corrigeant facilement. Bien que certains défauts existants puissent être corrigés après la sortie de l'interface, ne comptez pas là-dessus .
Les interfaces ne sont utilisées que pour définir des types
Un type d’ interface qui échoue est ce que l’on appelle l’ interface constante. Une telle interface ne contient aucune méthode ; elle contient uniquement des propriétés finales statiques, chacune produisant une constante. La classe qui utilise ces constantes implémente l'interface pour éviter d'avoir à qualifier le nom de la constante avec le nom de la classe.
Le modèle d'interface constant est une mauvaise utilisation de l'interface . Les classes utilisent certaines constantes en interne, qui sont entièrement des détails d'implémentation. L'implémentation d'une interface constante entraîne la fuite de ces détails d'implémentation dans l'API exportée de la classe. Pour l'utilisateur de la classe, cela n'a aucun sens que la classe implémente une interface constante. En fait, cela pourrait même les confondre. Pire, cela représente une promesse : si la classe est modifiée dans une future version pour ne plus avoir besoin d'utiliser de constantes, elle devra quand même implémenter l'interface pour assurer la compatibilité binaire. Si une classe non finale implémente une interface constante, les espaces de noms de toutes ses sous-classes seront pollués par les constantes de l'interface .
Si vous souhaitez exporter des constantes, il existe plusieurs options raisonnables. Si une constante est étroitement liée à une classe ou une interface existante, elle doit être ajoutée à cette classe ou interface. Par exemple, toutes les classes wrapper pour les types primitifs numériques, tels que Integer et Double, exportent les constantes MIN_VALUE et MAX_VALUE. Si les constantes peuvent être traitées comme membres d’un type énumération, elles doivent être exportées à l’aide du type énumération. Sinon, vous devez utiliser une classe utilitaire non instanciable pour exporter les constantes .
En bref, les interfaces ne peuvent être utilisées que pour définir des types. Ils ne doivent pas être utilisés uniquement pour exporter des constantes
Préférer la hiérarchie des classes aux classes de balises
Parfois, vous pouvez rencontrer une classe dont les instances ont deux styles ou plus et contiennent un champ de balise qui représente le style de l'instance. Par exemple, considérons cette classe, qui peut représenter un cercle ou un rectangle :
// Tagged class - vastly inferior to a class hierarchy!
class Figure {
enum Shape {
RECTANGLE,
CIRCLE
};
// Tag field - the shape of this figure
final Shape shape;
// These fields are used only if shape is RECTANGLE
double length; double width;
// This field is used only if shape is CIRCLE
double radius;
// Constructor for circle
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// Constructor for rectangle
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
double area() {
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}
De telles classes de balises présentent de nombreux inconvénients. Leur code standard désordonné comprend des déclarations d'énumération, des propriétés d'étiquette et des instructions switch. C'est moins lisible car plusieurs implémentations sont mélangées dans une seule classe .
Si vous ajoutez un style, n'oubliez pas d'ajouter une casse à chaque instruction switch, sinon la classe échouera au moment de l'exécution. Enfin, le type de données d'une instance ne fournit aucune indication sur le style. En bref, les classes de balises sont verbeuses, sujettes aux erreurs et inefficaces .
Heureusement, les langages orientés objet comme Java offrent une meilleure option pour définir un seul type de données pouvant représenter plusieurs styles d'objets : le sous-typage . Les classes de balises ne sont qu’une simple imitation d’une hiérarchie de classes.
Pour convertir une classe de balises en hiérarchie de classes, définissez d'abord une classe abstraite contenant des méthodes abstraites dont le comportement dépend de la valeur de la balise. Ensuite, définissez une sous-classe concrète de la classe racine pour chaque type de la classe d'étiquette d'origine.
// Class hierarchy replacement for a tagged class
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle extends Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override double area() {
return length * width;
}
}
Un autre avantage des hiérarchies de classes , augmentant ainsi la flexibilité et rendant la vérification des types au moment de la compilation plus efficace.
En bref, les classes d'étiquettes sont rarement applicables. Si vous souhaitez écrire une classe avec un attribut label explicite, déterminez si l'attribut label peut être supprimé et la classe remplacée par la hiérarchie de classes. Lorsque vous rencontrez une classe existante avec un attribut label, envisagez de la refactoriser dans une hiérarchie de classes .
Donner la priorité aux classes membres statiques
Une classe imbriquée est une classe définie au sein d’une autre classe. Les classes imbriquées ne doivent exister que dans leur classe englobante. Si une classe imbriquée est utile dans une autre situation, il doit s'agir d'une classe de niveau supérieur.
Il existe quatre types de classes imbriquées : les classes membres statiques, les classes membres non statiques, les classes anonymes et les classes partielles . À l’exception de la première, les trois autres sont appelées classes internes. Cette entrée vous indique quand utiliser quel type de classe imbriquée et pourquoi.
Une utilisation courante des classes membres statiques , utiles uniquement lorsqu'elles sont utilisées avec leurs classes externes. Par exemple, considérons un type d'énumération qui décrit les opérations prises en charge par une calculatrice (élément 34). L’énumération Operation doit être une classe membre statique publique de la classe Calculator. Les clients de calculatrice peuvent faire référence à des opérations en utilisant des noms tels que Calculator.Operation.PLUS et Calculator.Operation.MINUS.
Syntaxiquement, la seule différence entre les classes membres statiques et les classes membres non statiques est que les classes membres statiques ont le modificateur static dans leur déclaration. Chaque instance d'une classe membre non statique est implicitement associée à l'instance hôte de sa classe conteneur. Dans une méthode d'instance d'une classe membre non statique, vous pouvez appeler une méthode sur l'instance hôte ou obtenir une référence à l'instance hôte à l'aide d'un constructeur qualifié [JLS, 15.8.4]. Si les instances d'une classe imbriquée peuvent exister indépendamment des instances de sa classe hôte, alors la classe imbriquée doit être une classe membre statique : il n'est pas possible de créer une instance d'une classe membre non statique sans instance hôte .
L'association entre une instance de classe membre non . Normalement, l'association est automatiquement établie en appelant le constructeur de classe membre non statique dans la méthode d'instance de la classe hôte.
Si vous déclarez une classe membre qui ne nécessite pas d'accès à l'instance hôte, placez le modificateur static sur sa déclaration pour en faire une classe membre statique plutôt qu'une classe membre non statique . Si vous omettez ce modificateur, chaque instance aura une référence externe masquée à son instance hôte. Comme mentionné précédemment, stocker cette référence prend du temps et de l'espace. Ce qui est plus grave, c'est qu'il résidera toujours en mémoire même si la classe hôte remplit les conditions de garbage collection (élément 7). La fuite de mémoire qui en résulte peut être catastrophique. Les références étant invisibles, elles sont souvent difficiles à détecter.
Définir une seule classe de niveau supérieur dans un seul fichier source
Bien que le compilateur Java permette de définir plusieurs classes de niveau supérieur dans un seul fichier source, cela ne présente aucun avantage et comporte des risques importants. Le risque provient de la définition de plusieurs classes de niveau supérieur dans un fichier source, permettant de fournir plusieurs définitions pour une classe. La définition utilisée dépend de l'ordre dans lequel les fichiers sources sont transmis au compilateur.
// Two classes defined in one file. Don't ever do this!
//反例如下
class Utensil {
static final String NAME = "pan";
}
class Dessert {
static final String NAME = "cake";
}
Si vous essayez de regrouper plusieurs classes de niveau supérieur dans un seul fichier source, envisagez d'utiliser des classes membres statiques (élément 24) comme alternative à la division de la classe en fichiers source distincts. Si ces classes dépendent d'une autre classe, il est généralement préférable de les transformer en classes membres statiques, car cela améliore la lisibilité et peut réduire l'accessibilité de la classe en les déclarant private .
N'utilisez pas de types bruts directement
Une classe ou une interface dont la déclaration comporte un ou plusieurs paramètres de type (paramètres de type) est appelée classe générique ou interface générique. Par exemple, l'interface List possède un seul paramètre de type E, qui représente son type d'élément. Le nom complet de l'interface est List (prononcé « E » pour list), mais les gens l'appellent souvent List. Les classes et interfaces génériques sont collectivement appelées types génériques.
Chaque générique définit un type brut, qui est le nom du type générique sans aucun paramètre de type [JLS, 4.8]. Par exemple, le type primitif correspondant à List est List. Les types primitifs se comportent comme si toutes les informations de type générique avaient été effacées de la déclaration de type. Ils existent principalement pour la compatibilité avec le code pré-générique .
Il est légal d'utiliser des types primitifs (génériques sans paramètres de type), mais vous ne devriez pas le faire. Si vous utilisez des types primitifs, vous perdez toute la sécurité et les avantages expressifs des génériques . Pourquoi les concepteurs de langage ont-ils autorisé les types primitifs en premier lieu, étant donné que vous ne devriez pas les utiliser ? La réponse est pour la compatibilité.
Éliminez les avertissements non vérifiés
Lors de la programmation avec des génériques, vous verrez un certain nombre d'avertissements du compilateur : des avertissements de conversion non vérifiés, des avertissements d'appel de méthode non vérifiés, des avertissements de type de longueur variable paramétrés non vérifiés et des avertissements de conversion non vérifiés. Plus vous gagnez en expérience avec les génériques, moins vous recevrez d'avertissements, mais ne vous attendez pas à ce que le code nouvellement écrit se compile proprement.
Lorsque vous recevez un avertissement vous invitant à réfléchir davantage, persévérez ! Éliminez tous les avertissements non contrôlés possibles . Si vous éliminez tous les avertissements, vous pouvez être assuré que votre code est de type sécurisé, ce qui est une très bonne chose . Cela signifie que vous n'obtiendrez pas d'exception ClassCastException au moment de l'exécution et augmente votre confiance dans le fait que votre programme se comportera comme prévu.
Si vous ne pouvez pas supprimer un avertissement, mais que vous pouvez prouver que le code qui a provoqué l'avertissement est de type sécurisé, alors (et alors seulement) supprimez l'avertissement avec l'annotation @SuppressWarnings("unchecked") . Si vous supprimez les avertissements sans prouver au préalable que votre code est de type sécurisé, vous vous donnez un faux sentiment de sécurité. Le code peut être compilé sans aucun avertissement, mais il peut toujours lever une ClassCastException au moment de l'exécution.
L'annotation SuppressWarnings peut être utilisée sur n'importe quelle déclaration, depuis une seule déclaration de variable locale jusqu'à une classe entière. Utilisez toujours l'annotation SuppressWarnings dans la portée la plus petite possible . Il s'agit généralement d'une déclaration de variable ou d'une méthode ou d'un constructeur très court. N'utilisez jamais l'annotation SuppressWarnings sur une classe entière. Cela pourrait masquer des avertissements importants.
Chaque fois que vous utilisez l'annotation @SuppressWarnings("unchecked"), ajoutez un commentaire expliquant pourquoi elle est sûre. Cela aidera les autres à comprendre le code et, plus important encore, réduira la possibilité que quelqu'un modifie le code pour rendre le calcul dangereux.
Les listes valent mieux que les tableaux
Les tableaux diffèrent des génériques de deux manières importantes.
- Les tableaux sont covariants . Cela signifie que si Sub est un sous-type de Super, alors le type de tableau Sub[] est un sous-type du type de tableau Super[].
- Les génériques sont invariants : pour deux types différents, Type1 et Type2, List n'est ni un sous-type ni un supertype de List.
Vous pourriez penser que cela signifie que les génériques sont inadéquats, mais on pourrait affirmer que les baies sont déficientes. Ce code est légal :
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in";
// Throws ArrayStoreException
Mais celui-ci ne l’est pas :
// Won't compile!
List<Object> ol = new ArrayList<Long>();
// Incompatible types ol.add("I don't fit in");
Quoi qu'il en soit, vous ne pouvez pas placer un type String dans un conteneur de type Long, mais avec un tableau, vous trouverez une erreur au moment de l'exécution ; avec une liste, l'erreur peut être trouvée au moment de la compilation . Bien sûr, vous préférez trouver l’erreur au moment de la compilation.
La deuxième différence majeure entre les tableaux et les génériques est que les tableaux sont réifiés.
- Les tableaux connaissent et appliquent leurs types d'éléments au moment de l'exécution . Comme mentionné précédemment, si vous essayez de placer une chaîne dans un tableau Long, vous obtenez une ArrayStoreException.
- Les génériques sont implémentés par effacement. Cela signifie qu'ils appliquent uniquement les contraintes de type au moment de la compilation et suppriment (ou effacent) leurs informations de type d'élément au moment de l'exécution . Erasure garantit une transition en douceur vers les génériques dans Java 5 en permettant aux types génériques d'interagir librement avec le code existant qui n'utilise pas de génériques (élément 26).
En résumé, les tableaux et les génériques ont des règles de type très différentes. Les tableaux sont covariants et réifiés ; les génériques sont immuables et effacés. Par conséquent, les tableaux fournissent une sécurité de type au moment de l'exécution mais pas une sécurité de type au moment de la compilation, et vice versa . D'une manière générale, les tableaux et les génériques ne font pas bon ménage . Si vous vous retrouvez à les mélanger et à recevoir des erreurs ou des avertissements au moment de la compilation, votre première impulsion devrait être de remplacer le tableau par une liste.
Privilégier les génériques
Les types génériques . Lorsque vous concevez de nouveaux types, assurez-vous qu'ils peuvent être utilisés sans de tels moulages. Cela signifie généralement rendre le type générique. Si vous disposez de types existants qui devraient être génériques mais qui ne le sont pas, rendez-les génériques. Cela facilite l'utilisation pour les nouveaux utilisateurs de ces types sans casser les clients existants.
Préférer utiliser des méthodes génériques
En résumé, comme les types génériques, les méthodes génériques sont plus sûres et plus faciles à utiliser que les méthodes qui nécessitent que le client effectue des conversions explicites sur les paramètres d'entrée et les valeurs de retour. Comme pour les types, vous devez vous assurer que vos méthodes peuvent être utilisées sans conversion, ce qui signifie généralement qu'elles sont génériques. Les méthodes existantes doivent être génériques et leur utilisation nécessite un casting . Cela facilite l'utilisation par les nouveaux utilisateurs sans casser les clients existants.
Utilisez des caractères génériques qualifiés pour augmenter la flexibilité
Pour une flexibilité maximale, utilisez des types génériques pour les paramètres d’entrée qui représentent les producteurs ou les consommateurs. Si un paramètre d'entrée est à la fois producteur et consommateur, alors les types génériques ne vous servent à rien : vous avez besoin d'une correspondance de type exacte, ce qui est le cas sans aucun caractère générique.
Voici un mnémonique pour vous aider à vous rappeler quel type de caractère générique utiliser : PECS signifie : producteur-étend, consommateur-super. En d’autres termes, si un type paramétré représente un producteur T, utilisez <? extends T> ; s’il représente un consommateur T, utilisez <? super T>.
En résumé, l'utilisation de types génériques dans votre API, bien que délicate, rend l'API plus flexible . Si vous écrivez une bibliothèque de classes qui sera largement utilisée, l’utilisation correcte des types génériques doit être considérée comme obligatoire. N'oubliez pas la règle de base : producteur-étend, consommateur-super (PECS). N'oubliez pas non plus que tous les comparables et comparateurs sont des consommateurs.
Combinaison appropriée de paramètres génériques et variadiques
Pourquoi est-il légal de déclarer une méthode avec des paramètres variadiques génériques, alors que la création explicite d'un tableau générique est illégale ? La réponse est que les méthodes avec des arguments variadiques de types génériques ou paramétrés peuvent être très utiles en pratique , c'est pourquoi les concepteurs de langage choisissent de vivre avec cette incohérence. En fait, la bibliothèque de classes Java exporte plusieurs de ces méthodes, notamment Arrays.asList(T… a), Collections.addAll(Collection<? super T> c, T… elements), EnumSet.of(E first, E… rest) . Contrairement aux méthodes dangereuses présentées précédemment, ces méthodes de bibliothèque sont de type sécurisé.
Dans Java 7, l'annotation @SafeVarargs a été ajoutée à la plateforme pour permettre aux auteurs de méthodes avec des arguments variadiques génériques de supprimer automatiquement les avertissements des clients . Essentiellement, l'annotation @SafeVarargs constitue l'engagement de l'auteur envers une méthode de type sécurisé. En échange de cette promesse, le compilateur s'engage à ne pas avertir les utilisateurs de l'appel de méthodes potentiellement dangereuses.
Veillez à ne pas annoter une méthode avec l'annotation @SafeVarargs à moins qu'elle ne soit réellement sûre. Alors, que faut-il faire pour garantir cela ? Rappelez-vous que lorsqu'une méthode est appelée, un tableau générique est créé pour contenir les arguments variadiques. Une méthode est sûre si elle ne stocke rien dans le tableau (elle écrase les paramètres) et ne permet pas d'échapper les références au tableau (ce qui permet à du code non fiable d'accéder au tableau). En d’autres termes, si le tableau variadique est utilisé uniquement pour transmettre un nombre variable d’arguments à la méthode depuis l’appelant (ce qui est, après tout, le but des arguments variadiques), alors la méthode est sûre.
En résumé, les varargs et les génériques n'interagissent pas bien car le mécanisme varargs est une abstraction fragile construite sur des tableaux, et les tableaux ont des règles de type différentes de celles des génériques. Bien que les paramètres variadiques génériques ne soient pas de type sécurisé, ils sont légaux. Si vous choisissez d'écrire une méthode à l'aide de varargs génériques (ou paramétrés), assurez-vous d'abord que la méthode est de type sécurisé, puis annotez-la avec l'annotation @SafeVarargs pour éviter une utilisation désagréable.
Donner la priorité aux conteneurs hétérogènes de type sécurisé
Les utilisations courantes des génériques incluent les collections, telles que Set et Map<K,V>, et les conteneurs à élément unique, tels que ThreadLocal et AtomicReference. Dans toutes ces utilisations, il s'agit d'un conteneur paramétré. Cela limite chaque conteneur à un nombre fixe de paramètres de type. Habituellement, c'est ce que vous voulez. Un Set a un seul paramètre de type, représentant son type d'élément ; un Map en a deux, représentant ses types de clé et de valeur ; et ainsi de suite.
Parfois, cependant, vous avez besoin de plus de flexibilité. Par exemple, une ligne de base de données peut avoir n'importe quel nombre de colonnes, et il est agréable de pouvoir y accéder de manière sécurisée. Heureusement, il existe un moyen simple d’obtenir cet effet. L'idée est de paramétrer des clés plutôt que des conteneurs. La clé paramétrée est ensuite soumise au conteneur pour insérer ou récupérer une valeur. Le système de types génériques est utilisé pour garantir que le type d'une valeur est cohérent avec sa clé .
Comme exemple simple de cette approche, considérons une classe Favoris qui permet à ses clients de sauvegarder et de récupérer des instances favorites de n'importe quel nombre de types. Les objets de classe de ce type feront partie de la clé paramétrée . La raison est que cette classe Class est générique. Le type de classe n’est pas simplement Classe littéralement, mais Classe. Par exemple, String.class est de type Class et Integer.class est de type Class. Lorsqu’une classe littérale est transmise dans une méthode pour transmettre des informations de type au moment de la compilation et de l’exécution, elle est appelée jeton de type.
Exemple de code :
// Typesafe heterogeneous container pattern - API
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}
En bref, l'utilisation courante d'API génériques (en prenant l'API de collection comme exemple) limite chaque conteneur à un nombre fixe de paramètres de type. Vous pouvez contourner cette limitation en plaçant le paramètre type sur la clé plutôt que sur le conteneur . Vous pouvez utiliser des objets Class comme clés pour ce conteneur hétérogène de type sécurisé. Les objets de classe utilisés de cette manière sont appelés jetons de type. Des types de clés personnalisés peuvent également être utilisés. Par exemple, vous pouvez avoir un type DatabaseRow qui représente une ligne de base de données (conteneur) et un type générique Column comme clé.
Utiliser des types d'énumération au lieu de constantes entières
Avant que les types d'énumération ne soient ajoutés au langage, un modèle courant pour représenter les types d'énumération consistait à déclarer un ensemble de constantes nommées ints, une pour chaque membre du type :
// The int enum pattern - severely deficient!
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
Cette technique, connue sous le nom de mode d'énumération int, présente de nombreux inconvénients. Il n’offre aucune sécurité de type ni aucune expressivité. Il n'existe pas de moyen simple de convertir une constante d'énumération int en une chaîne imprimable. Si vous imprimez une telle constante ou l'affichez depuis le débogueur, tout ce que vous voyez est un nombre, ce qui n'est pas très utile. Il n'existe aucun moyen fiable de parcourir toutes les constantes d'énumération int d'un groupe, ni même d'obtenir la taille d'un groupe d'énumération int .
Heureusement, Java propose une alternative qui évite tous les inconvénients des modèles d'énumération int et String et offre de nombreux avantages supplémentaires.
L'idée de base derrière les types d'énumération Java est simple : ce sont des classes qui exportent une instance pour chaque constante d'énumération via une propriété finale statique publique. Puisqu'il n'y a pas de constructeurs accessibles, les types d'énumération sont en réalité définitifs. Puisqu'un client ne peut ni créer une instance d'un type d'énumération ni en hériter, il ne peut y avoir d'instances autres que les constantes d'énumération déclarées . En d'autres termes, les types énumérés sont contrôlés par l'instance (page 6). Ce sont des génériques de singletons (Item 3), qui sont essentiellement des énumérations à un seul élément.
Les énumérations assurent la sécurité des types au moment de la compilation . Tenter de transmettre une valeur d'un type incorrect entraînera une erreur de compilation car une tentative sera effectuée pour attribuer une expression d'un type d'énumération à une variable d'un autre type, ou pour utiliser l'opérateur == pour comparer les valeurs. de différents types d’énumération.
Il existe une meilleure façon d'associer un comportement différent à chaque constante enum : déclarez une méthode d'application abstraite dans le type enum et remplacez-la par une méthode concrète pour chaque constante dans le corps de classe spécifique à la constante. Cette approche est appelée implémentation de méthode spécifique à une constante :
// Enum type with constant-specific method implementations
public enum Operation {
PLUS {
public double apply(double x, double y){
return x + y;}},
MINUS {
public double apply(double x, double y){
return x - y;}},
TIMES {
public double apply(double x, double y){
return x * y;}},
DIVIDE{
public double apply(double x, double y){
return x / y;}};
public abstract double apply(double x, double y);
}
Si vous ajoutez de nouvelles constantes à l'opération dans l'exemple ci-dessus, il est peu probable que vous oubliiez de fournir la méthode apply car elle suit chaque déclaration de constante. En cas d'oubli, le compilateur vous le rappellera car les méthodes abstraites des types énumération doivent être remplacées par des méthodes concrètes dans toutes les constantes .
En bref, les avantages des types énumération par rapport aux constantes int sont convaincants. Les énumérations sont plus lisibles, plus sûres et plus puissantes. De nombreuses énumérations ne nécessitent pas de constructeurs ou de membres explicites, mais d'autres bénéficient de l'association de données à chaque constante et de la fourniture de méthodes dont le comportement est affecté par ces données. L’utilisation d’une seule méthode pour associer plusieurs comportements réduit le dénombrement. Dans ce cas relativement rare, préférez utiliser des méthodes spécifiques aux constantes pour énumérer vos propres valeurs. Si certaines constantes d'énumération (mais pas toutes) partagent un comportement commun, considérez le modèle d'énumération stratégique.
Utiliser des propriétés d'instance au lieu de nombres ordinaux
Ne dérivez jamais la valeur associée à une énumération de son numéro ordinal ; enregistrez-la dans une propriété d'instance :
public enum Ensemble {
SOLO(1),
DUET(2),
TRIO(3),
QUARTET(4),
QUINTET(5),
SEXTET(6),
SEPTET(7),
OCTET(8),
DOUBLE_QUARTET(8),
NONET(9),
DECTET(10),
TRIPLE_QUARTET(12);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
Utilisez EnumSet au lieu des propriétés de bits
Vous trouverez ci-dessous l'exemple précédent d'utilisation d'énumérations et de collections d'énumérations au lieu de propriétés de bits. C'est plus court, plus clair et plus sûr :
// EnumSet - a modern replacement for bit fields
public class Text {
public enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
// Any Set could be passed in, but EnumSet is clearly best
public void applyStyles(Set<Style> styles) {
... }
}
En résumé, simplement parce que le type énumération sera utilisé dans une collection, il n'y a aucune raison de le représenter avec un attribut bit. La classe EnumSet combine la simplicité et les performances des propriétés de bits avec tous les avantages du type énumération décrit au point 34. Un véritable inconvénient d'EnumSet est qu'il ne crée pas d'EnumSet immuable comme dans Java 9, mais cela pourrait être corrigé dans une prochaine version. Dans le même temps, vous pouvez utiliser Collections.unmodifiableSet pour encapsuler un EnumSet, mais la simplicité et les performances en seront affectées.
Utilisez EnumMap au lieu de l'index ordinal
En bref, utiliser des nombres ordinaux pour indexer des tableaux est inapproprié : utilisez plutôt EnumMap. Si la relation que vous représentez est multidimensionnelle, utilisez EnumMap<…, EnumMap<…>>. Les programmeurs d'applications devraient rarement utiliser Enum.ordinal (élément 35), et s'il est utilisé, il s'agit d'un cas particulier du principe général.
Exemple d'utilisation :
// Adding a new phase using the nested EnumMap implementation
public enum Phase {
SOLID, LIQUID, GAS, PLASMA;
public enum Transition {
MELT(SOLID, LIQUID),
FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS),
CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS),
DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA),
DEIONIZE(PLASMA, GAS); ...
// Remainder unchanged
}
}
Implémenter des énumérations extensibles à l'aide d'interfaces
La plupart du temps, l’extensibilité des énumérations est une mauvaise idée. Ce qui prête à confusion, c'est que les éléments d'un type étendu sont des instances du type de base et vice versa. Il n’existe aucun bon moyen d’énumérer tous les éléments d’un type de base et ses extensions. Enfin, l’évolutivité complique de nombreux aspects de la conception et de la mise en œuvre.
Cela dit, il existe au moins un cas d’utilisation convaincant pour les types d’énumération extensibles, à savoir les codes d’opération , également appelés opcodes. Les opcodes sont des types énumérés dont les éléments représentent des opérations sur une machine, comme le type Operation de l'élément 34, qui représente des fonctions sur une simple calculatrice. Parfois, il est nécessaire de laisser les utilisateurs de l'API effectuer leurs propres opérations, étendant ainsi efficacement l'ensemble des opérations fournies par l'API.
// Emulated extensible enum using an interface
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements Operation {
PLUS("+") {
public double apply(double x, double y) {
return x + y; }
},
MINUS("-") {
public double apply(double x, double y) {
return x - y; }
},
TIMES("*") {
public double apply(double x, double y) {
return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) {
return x / y; }
};
private final String symbol;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override public String toString() {
return symbol;
}
}
En bref, bien que vous ne puissiez pas écrire un type d'énumération extensible, vous pouvez écrire une interface correspondant au type d'énumération de base qui implémente l'interface pour la simuler. Cela permet aux clients d'écrire leurs propres énumérations (ou d'autres types) qui implémentent l'interface. Si l'API est écrite en termes d'interfaces, ces instances de type enum peuvent être utilisées partout où des instances de type enum de base sont utilisées.
Les annotations valent mieux que le nom
Dans le passé , les modèles de dénomination étaient souvent utilisés pour indiquer que certains éléments du programme nécessitaient un traitement spécial par un outil ou un framework . Par exemple, avant la version 4, le framework de test JUnit exigeait que ses utilisateurs spécifient les méthodes de test en commençant le nom par test[Beck04]. Cette technique est efficace, mais elle présente plusieurs inconvénients majeurs. Premièrement, une faute de frappe provoque un échec mais n’invite pas. Par exemple, supposons que vous ayez accidentellement nommé la méthode de test tsetSafetyOverride au lieu de testSafetyOverride. JUnit 3 ne générera pas d'erreurs, mais il n'exécutera pas non plus les tests, conduisant à un faux sentiment de sécurité.
Un deuxième inconvénient des modèles de dénomination est l’incapacité de garantir qu’ils sont utilisés uniquement pour les éléments de programme appropriés.
Un troisième inconvénient des modèles de dénomination est qu'ils ne fournissent pas un bon moyen d'associer les valeurs des paramètres aux éléments du programme. Par exemple, supposons que vous souhaitiez prendre en charge une classe de tests qui ne réussissent que lorsqu'une exception spécifique est levée. Le type d’exception est essentiellement un paramètre du test. Vous pouvez utiliser un modèle de dénomination élaboré pour coder le nom du type d'exception dans le nom de la méthode de test, mais cela devient moche et fragile.
Le framework de test de ce projet n'est qu'une démo, mais il démontre clairement la supériorité des annotations sur les modèles nommés, et il ne donne qu'un aperçu de ce que vous pouvez en faire. Si vous écrivez un outil qui oblige les programmeurs à ajouter des informations au code source, définissez les types d'annotations appropriés. Il n'y a aucune raison d'utiliser des modèles de dénomination lorsque vous pouvez utiliser des annotations à la place .
Cela signifie que la plupart des programmeurs, à l'exception des développeurs spécifiques (outilleurs), n'ont pas besoin de définir des types d'annotations. Mais tous les programmeurs devraient utiliser les types d'annotations prédéfinis fournis par Java (éléments 40, 27). Pensez également à utiliser les annotations fournies par votre IDE ou votre outil d'analyse statique. Ces annotations peuvent améliorer la qualité des informations de diagnostic fournies par ces outils. Notez cependant que ces annotations ne sont pas encore standardisées, donc des travaux supplémentaires peuvent être nécessaires si des outils ou des normes changent.
Utilisez toujours l'annotation Remplacer
Par conséquent, vous devez utiliser l'annotation Override sur chaque déclaration de méthode qui, selon vous, remplacera la déclaration de classe parent . Il existe une petite exception à cette règle. Si vous écrivez une classe qui n'est pas marquée comme abstraite et que vous êtes sûr qu'elle remplace une méthode abstraite dans sa classe parent, vous n'avez pas besoin de mettre une annotation Override sur la méthode. Dans une classe qui n'est pas déclarée abstraite, le compilateur émettra un message d'erreur si la méthode de la classe parent abstraite ne peut pas être remplacée. Cependant, vous souhaiterez peut-être vous concentrer sur toutes les méthodes de votre classe qui remplacent les méthodes de la classe parent, auquel cas vous devez toujours annoter également ces méthodes. La plupart des IDE peuvent être configurés pour insérer automatiquement une annotation Override lorsqu'une méthode remplacée est sélectionnée.
L'annotation Override peut être utilisée pour remplacer les déclarations de méthodes des interfaces et des classes. Avec l'avènement des méthodes par défaut, c'est une bonne pratique d'utiliser Override sur l'implémentation concrète de la méthode d'interface pour s'assurer que la signature est correcte. Si vous savez qu'une interface n'a pas de méthode par défaut, vous pouvez choisir d'ignorer l'annotation Override sur l'implémentation spécifique de la méthode d'interface pour réduire la confusion.
Définir des types à l'aide d'interfaces de marqueurs
Une interface de marqueur ne contient pas de déclarations de méthode, mais spécifie simplement (ou « marque ») une classe qui implémente une interface avec certaines propriétés . Par exemple, considérons l'interface Serialisable (Chapitre 12). En implémentant cette interface, une classe indique que ses instances peuvent écrire (ou « sérialiser ») un ObjectOutputStream.
Vous avez peut-être entendu parler de l'annotation de marquage (élément 39) marquant une interface comme obsolète. Cette affirmation est incorrecte. Les interfaces de marqueurs présentent deux avantages par rapport aux annotations de marqueurs :
- Premièrement, l'interface des marqueurs définit un type qui est implémenté par les instances de la classe des marqueurs ; ce n'est pas le cas des annotations des marqueurs . La présence d'un type d'interface de marqueur permet de détecter les erreurs au moment de la compilation, alors que si des annotations de marqueur sont utilisées, les erreurs ne peuvent pas être détectées avant l'exécution ;
- Un autre avantage de l'interface des marqueurs pour les annotations des marqueurs est qu'elles peuvent être ciblées plus précisément . Si un type d'annotation est déclaré à l'aide de la cible ElementType.TYPE, il peut être appliqué à n'importe quelle classe ou interface. Supposons qu'il existe une balise qui s'applique uniquement aux implémentations d'une interface spécifique. S'il est défini comme interface de marqueur, vous pouvez étendre l'interface unique à laquelle il s'applique, en garantissant que tous les types de marqueurs sont également des sous-types de l'interface unique à laquelle il s'applique ;
En résumé, les interfaces de marqueurs et les annotations de marqueurs ont leur utilité. Si vous souhaitez définir un type sans aucune nouvelle méthode associée, une interface balisée est la solution. Les annotations de balisage sont le bon choix si vous souhaitez marquer des éléments de programme autres que les classes et les interfaces, ou si vous souhaitez conformer le balisage dans un cadre qui utilise déjà beaucoup de types d'annotations. Si vous vous retrouvez à écrire un type d'annotation balisé ciblant ElementType.TYPE, prenez un moment pour déterminer s'il doit s'agir d'un type d'annotation et si une interface balisée serait plus appropriée .
Les expressions lambda sont meilleures que les classes anonymes
Contrairement aux méthodes et aux classes, les lambdas n'ont ni nom ni documentation ; si le calcul n'est pas explicite ou dépasse quelques lignes, ne le mettez pas dans une expression lambda . Une ligne de code est idéale pour un lambda, et trois lignes de code constituent une valeur raisonnablement grande. La violation de cette règle peut sérieusement nuire à la lisibilité de votre programme. Si un lambda est long ou difficile à lire, trouvez un moyen de le simplifier ou refactorisez votre programme pour l'éliminer.
De même, on pourrait penser que les classes anonymes sont obsolètes à l’ère des lambdas. C'est plus proche de la vérité, mais il y a certaines choses que vous pouvez faire avec des classes anonymes que vous ne pouvez pas faire avec des lambdas. Lambda est limité aux interfaces fonctionnelles. Si vous souhaitez créer une instance d'une classe abstraite, vous pouvez le faire en utilisant une classe anonyme, mais pas une lambda. De même, vous pouvez utiliser des classes anonymes pour créer des instances d'interface avec plusieurs méthodes abstraites . Enfin, le lambda ne peut pas obtenir de référence à lui-même. Dans les lambdas, le mot-clé this fait référence à l'instance englobante, qui est généralement ce que vous voulez. Dans une classe anonyme, le mot-clé this fait référence à l'instance de classe anonyme. Si vous devez accéder à un objet fonction depuis celui-ci, vous devez utiliser une classe anonyme.
En résumé, à partir de Java 8, lambda est de loin le meilleur moyen de représenter de petits objets fonction. N'utilisez pas de classes anonymes comme objets de fonction, sauf si vous devez créer des instances de types d'interface non fonctionnels .
Les références de méthodes sont meilleures que les expressions lambda
Le principal avantage de lambda par rapport aux classes anonymes est qu'elle est plus concise. Java fournit un moyen de générer des objets de fonction, qui est plus concis que les références de méthode lambda :.
//lambda
map.merge(key, 1, (count, incr) -> count + incr);
//method references
map.merge(key, 1, Integer::sum);
Ils vous donnent également une conséquence si le lambda devient trop long ou complexe : vous pouvez extraire le code du lambda dans une nouvelle méthode et remplacer le lambda par une référence à cette méthode . Vous pouvez donner un bon nom à cette méthode et la documenter.
Les références de méthodes Si les références de méthode semblent plus courtes et plus claires, utilisez-les ; sinon, restez fidèle à lambdas .
Préférer les interfaces fonctionnelles standards
Le package java.util.function fournit un grand nombre d’interfaces fonctionnelles standard que vous pouvez utiliser. Si l’une des interfaces fonctionnelles standard fait l’affaire, vous devez généralement l’utiliser de préférence à une interface fonctionnelle spécialement conçue . Cela rendra votre API plus facile à apprendre en réduisant ses concepts inutiles et offrira des avantages d'interopérabilité importants car de nombreuses interfaces fonctionnelles standard fournissent des méthodes par défaut utiles. Par exemple, l'interface Predicate fournit des méthodes permettant de combiner des jugements. Dans notre exemple LinkedHashMap, l’interface standard BiPredicate<Map<K,V>, Map.Entry<K,V>> doit avoir priorité sur l’utilisation de l’interface personnalisée EldestEntryRemovalFunction.
Il y a 43 interfaces dans java.util.Function. Vous ne pouvez pas vous attendre à les mémoriser toutes, mais si vous vous souvenez des six interfaces de base, vous pouvez dériver le reste lorsque vous en avez besoin. L'interface de base fonctionne sur les types de référence d'objet.
- L'interface Opérateur représente une méthode dont les types de résultat et de paramètre sont du même type.
- L'interface Predicate signifie que sa méthode accepte un paramètre et renvoie une valeur booléenne.
- L'interface de fonction représente des méthodes dont les paramètres et les types de retour sont différents.
- L'interface Fournisseur représente une méthode qui ne prend aucun paramètre et renvoie une valeur (ou « approvisionnement »).
- Consumer signifie que la méthode accepte un paramètre et ne renvoie rien, essentiellement en utilisant son paramètre.
Les six interfaces fonctionnelles de base sont résumées comme suit :
[Échec du transfert de l'image du lien externe. Le site source peut avoir un mécanisme anti-sangsue. Il est recommandé de sauvegarder l'image et de la télécharger directement (img-lEnJWnyc-1685157110629) (C:\Users\lixuewen\AppData\Roaming\Typora\ typora-user-images\ image-20230522140251781.png)]
En bref, Java dispose désormais d'expressions lambda, vous devez donc prendre en compte les expressions lambda pour concevoir votre API. Accepte les types d’interface fonctionnelle en entrée et les renvoie en sortie. D'une manière générale, il est préférable d'utiliser les interfaces standards fournies dans java.util.function.Function, mais veuillez noter que dans des cas relativement rares, il est préférable d'écrire votre propre interface fonctionnelle.
Utilisez Streams à bon escient et judicieusement
L'API Stream a été ajoutée à Java 8 pour simplifier la tâche d'exécution d'opérations par lots de manière séquentielle ou en parallèle. L'API fournit deux abstractions clés : les flux, qui représentent des séquences finies ou infinies d'éléments de données, et les pipelines de flux, qui représentent des calculs à plusieurs niveaux sur ces éléments. Les éléments d’un Stream peuvent provenir de n’importe où. Les sources courantes incluent les collections, les tableaux, les fichiers, les correspondances de modèles d'expressions régulières, les générateurs de nombres pseudo-aléatoires et d'autres flux.
Un pipeline de flux . Chaque opération intermédiaire transforme le flux d'une manière ou d'une autre, par exemple en mappant chaque élément à une fonction sur cet élément ou en filtrant tous les éléments qui ne remplissent pas certaines conditions. Les opérations intermédiaires transforment toutes un flux en un autre flux, dont les types d'éléments peuvent ou non être les mêmes que ceux du flux d'entrée. Finalisation Un calcul final résultant d'une opération intermédiaire effectuée sur un flux, comme stocker ses éléments dans une collection, renvoyer un élément ou imprimer tous ses éléments .
Améliorez la lisibilité en . L'utilisation de méthodes d'assistance est plus importante pour la lisibilité dans les pipelines de streaming que dans le code itératif, car les pipelines manquent d'informations de type explicites et de variables temporaires nommées. (Fonctions extraites pour simplifier le code)
Lorsque vous commencez à utiliser des flux, vous ressentirez peut-être le besoin de convertir toutes vos boucles en flux, mais résistez à cette envie. Bien que cela soit possible, cela peut nuire à la lisibilité et à la maintenabilité de votre base de code. En règle générale, les tâches moyennement complexes fonctionnent bien en utilisant une combinaison de flux et d'itérations. Alors, refactorisez le code existant pour utiliser les flux et utilisez-les dans le nouveau code uniquement lorsque cela a du sens .
En bref, certaines tâches sont mieux accomplies à l'aide de flux, et d'autres tâches sont mieux accomplies à l'aide d'itérations. La combinaison de ces deux méthodes peut bien accomplir de nombreuses tâches. Il n’existe pas de règles strictes pour choisir la méthode à utiliser pour une tâche, mais il existe quelques heuristiques utiles. Dans de nombreux cas, la méthode à utiliser sera claire ; dans certains cas, elle ne le sera pas. Si vous ne savez pas si une tâche est mieux accomplie par flux ou par itération, essayez les deux méthodes et voyez laquelle fonctionne le mieux.
Donner la priorité aux fonctions sans effets secondaires dans les flux
Les programmeurs Java savent comment utiliser les boucles for-each et l'opération de finalisation forEach est similaire. Mais l'opération forEach est l'une des opérations les moins puissantes des opérations de terminal, et c'est également une opération de flux peu conviviale. Il est explicitement itératif et ne convient donc pas à la parallélisation. L'opération forEach ne doit être utilisée que pour rapporter les résultats d'un calcul de flux, et non pour effectuer le calcul . Parfois, il est judicieux d'utiliser forEach à d'autres fins, par exemple pour ajouter les résultats d'un calcul de flux à une collection préexistante.
Le code amélioré utilise des collecteurs, un nouveau concept qui doit être appris lorsque l'on travaille avec des flux. L'API des collectionneurs est intimidante : elle comporte 39 méthodes, dont certaines ont jusqu'à cinq paramètres de type. La bonne nouvelle est que vous pouvez tirer la plupart des avantages de cette API sans avoir à approfondir toute sa complexité. Pour commencer, vous pouvez ignorer l’interface du collecteur et considérer le collecteur comme un objet opaque qui encapsule une stratégie de réduction. Un collecteur qui rassemble les éléments d’un flux dans une véritable collection est très simple. Il existe trois collecteurs de ce type : toList(), toSet() toCollection(collectionFactory). Ils renvoient respectivement des ensembles, des listes et des types de collections spécifiés par le programmeur .
En bref, l'essence du pipeline de flux de programmation est un objet fonction sans effets secondaires. Cela s'applique à tous les nombreux objets de fonction transmis aux flux et aux objets associés. L'opération finale forEach ne doit être utilisée que pour rapporter les résultats des calculs effectués par le flux, et non pour effectuer les calculs. Afin d'utiliser correctement les flux, vous devez comprendre les collectionneurs. Les fabriques de collecteurs importantes sont toList, toSet, toMap, groupingBy et join.
Préférer la collection au flux comme type de retour
L'interface Collection est un sous-type d'Iterable et possède une méthode stream, elle fournit donc à la fois un accès par itération et par flux. Par conséquent, Collection ou un sous - type approprié est généralement le meilleur type de retour pour les méthodes de retour de séquence publique . Les tableaux fournissent également une itération simple et un accès au flux à l'aide des méthodes Arrays.asList et Stream.of. Si la séquence renvoyée est suffisamment petite pour tenir facilement en mémoire, il est préférable de renvoyer une implémentation de collection standard telle qu'une ArrayList ou un HashSet. Mais ne stockez pas une grande séquence en mémoire juste pour la renvoyer sous forme de collection.
En résumé, lorsque vous écrivez des méthodes qui renvoient des séquences d'éléments, gardez à l'esprit que certains utilisateurs peuvent souhaiter les traiter sous forme de flux, tandis que d'autres souhaitent les traiter de manière itérative. Essayez d'accommoder les deux groupes. Si le retour d'une collection est possible, faites-le. Si vous avez déjà les éléments dans la collection ou si le nombre d'éléments dans la séquence est suffisamment petit pour créer un nouvel élément, renvoyez une collection standard, telle qu'une ArrayList. Sinon, envisagez d'implémenter un ensemble personnalisé, comme nous l'avons fait pour le programme Power Set. Si le retour d'une collection n'est pas possible, renvoyez un flux ou un itérable, selon ce qui semble le plus naturel. Si dans une future version de Java, la déclaration de l'interface Stream est modifiée pour hériter d'Iterable, alors n'hésitez pas à renvoyer des flux car ils permettront le streaming et le traitement itératif.
Utilisez le parallélisme de flux avec prudence
Ne parallélisez pas sans discernement les pipelines de flux . Les conséquences sur les performances peuvent être catastrophiques.
En général, les gains de performances grâce au parallélisme sont meilleurs sur les flux d’instances ArrayList, HashMap, HashSet et ConcurrentHashMap, les tableaux, les plages de type int et les plages de type long. Ce que ces structures de données ont en commun, c'est qu'elles peuvent être divisées avec précision et à moindre coût en sous-programmes de n'importe quelle taille, ce qui facilite la répartition du travail entre des threads parallèles. L'abstraction utilisée par la bibliothèque Teardrop pour effectuer cette tâche est un spliterator, qui est renvoyé par les méthodes spliterator sur Streams et Iterables.
En résumé, n'essayez même pas de paralléliser un pipeline de streaming à moins que vous n'ayez de bonnes raisons de croire qu'il maintiendra le calcul correct et augmentera sa vitesse . Le coût d'une parallélisation incorrecte d'un flux peut entraîner une défaillance du programme ou un désastre en termes de performances. Si vous pensez que le parallélisme est raisonnable, assurez-vous que votre code se comporte correctement lorsqu'il est exécuté en parallèle et effectuez des mesures de performances minutieuses dans des conditions réelles. Si votre code est correct et que ces expériences confirment vos soupçons concernant l’amélioration des performances, alors et alors seulement pourrez-vous paralléliser le flux dans le code de production.
Vérifier la validité des paramètres
Si une valeur de paramètre non valide est transmise à une méthode et que la méthode vérifie ses paramètres avant de s'exécuter, elle lève une exception appropriée, puis échoue rapidement et proprement . Si la méthode ne parvient pas à vérifier ses paramètres, quelque chose peut arriver. Pendant le traitement, la méthode peut générer des exceptions déroutantes. Pire encore, la méthode renvoie normalement mais calcule silencieusement un résultat erroné. Ce qui est pire, c'est que la méthode retourne normalement mais laisse un objet dans un état corrompu, provoquant une erreur à un moment sans rapport dans le code à un moment indéterminé dans le futur. En d’autres termes, l’échec de la validation des paramètres peut entraîner une violation de l’atomicité de défaillance.
Les constructeurs sont un cas particulier de ce principe, et vous devez vérifier la validité des paramètres que vous souhaitez stocker pour une utilisation ultérieure . Vérifier la validité des paramètres du constructeur est important pour empêcher les objets construits de violer les invariants de classe.
Vous devez vérifier explicitement les paramètres de la méthode avant d'effectuer des calculs, mais il existe des exceptions à cette règle. Une exception importante concerne les cas où le contrôle de validité est coûteux ou peu pratique et où le contrôle est effectué implicitement dans le cadre du calcul.
En résumé, chaque fois que vous écrivez une méthode ou un constructeur, vous devez tenir compte des restrictions qui existent sur ses paramètres . Ces restrictions doivent être notées et appliquées à l'aide de vérifications explicites au début du corps de la méthode. Il est important de prendre l’habitude de le faire. Le peu de travail que cela demande sera récompensé lorsque le premier contrôle de validité échouera.
Faire des copies défensives si nécessaire
Même dans une langue sûre, l’isolement des autres classes n’est pas possible sans quelques efforts. Les programmes doivent être écrits de manière défensive, en supposant que les clients d'une classe s'efforcent de détruire les invariants de la classe . Cela devient de plus en plus vrai à mesure que les gens s'efforcent de briser la sécurité du système, mais le plus souvent, vos classes devront faire face à un comportement inattendu dû à des erreurs honnêtes commises par des programmeurs bien intentionnés. Quoi qu'il en soit, cela vaut la peine de consacrer du temps à l'écriture de cours qui restent robustes malgré le mauvais comportement des clients.
Dans la mesure du possible, vous devez utiliser des objets immuables comme composants d'objets afin de ne pas avoir à vous soucier des copies défensives (point 17). Dans notre exemple de période, utilisez Instant (ou LocalDateTime ou ZonedDateTime) sauf si vous utilisez une version antérieure à Java 8. Si vous utilisez une version antérieure, une option consiste à stocker le type de base long renvoyé par Date.getTime() au lieu de la référence Date.
La copie défensive peut entraîner une baisse des performances, mais celle-ci n'est pas toujours justifiée. Si une classe fait confiance à ses appelants pour ne pas modifier les composants internes, peut-être parce que la classe et ses clients font partie du même package, alors elle n'a peut-être pas besoin de copie défensive. Dans ces cas, la documentation de la classe doit clairement indiquer que l'appelant ne peut pas modifier les paramètres ou les retours concernés.
En résumé, si une classe possède des composants mutables obtenus ou renvoyés par ses clients, alors la classe doit copier ces composants de manière défensive. Si le coût de la copie est trop élevé et que le cours fait confiance à ses clients pour ne pas modifier les composants de manière inappropriée, la copie défensive peut être remplacée par un document soulignant la responsabilité du client de ne pas modifier les composants concernés.
Concevoir soigneusement les signatures de méthodes
Choisissez soigneusement . Les noms doivent toujours respecter les conventions de dénomination standard (article 68). Votre objectif principal devrait être de choisir un nom cohérent avec les autres noms du même package et facile à comprendre. La deuxième étape devrait consister à choisir un nom cohérent avec le consensus plus large. Évitez d'utiliser des noms de méthodes longs ;
N'en faites pas trop en proposant des méthodes pratiques . Chaque méthode doit être appliquée « au mieux de ses capacités ». Trop de méthodes rendent une classe difficile à apprendre, à utiliser, à documenter, à tester et à maintenir. Cela est particulièrement vrai pour les interfaces, où un trop grand nombre de méthodes compliquent le travail des implémenteurs et des utilisateurs ;
Évitez les listes de paramètres trop longues . Visez quatre paramètres ou moins. La plupart des programmeurs ne peuvent pas se souvenir de listes de paramètres plus longues.
Pour les types de paramètres, préférez les interfaces aux classes (élément 64). S'il existe une interface appropriée qui définit un paramètre, utilisez-la pour prendre en charge une classe qui implémente cette interface. Par exemple, il n'y a aucune raison d'utiliser un HashMap comme paramètre d'entrée lors de l'écriture d'une méthode. Au lieu de cela, utilisez un Map comme paramètre, ce qui permet de passer un HashMap, un TreeMap, un ConcurrentHashMap, une sous-carte d'un TreeMap ou tout autre Implémentation de la carte qui n'a pas encore été écrite.
Utilisez des types d'énumération à deux éléments de préférence aux , sauf si la signification du paramètre booléen est claire dans le nom de la méthode. Les types Enum facilitent la lecture et l’écriture du code. De plus, ils facilitent l’ajout d’options supplémentaires ultérieurement .
Utiliser la surcharge à bon escient et judicieusement
Exemple de code :
public class CollectionClassifier {
public static String classify(Set<?> s) {
return "Set";
}
public static String classify(List<?> lst) {
return "List";
}
public static String classify(Collection<?> c) {
return "Unknown Collection";
}
public static void main(String[] args) {
Collection<?>[] collections = {
new HashSet<String>(),
new ArrayList<BigInteger>(),
new HashMap<String, String>().values()
};
for (Collection<?> c : collections)
System.out.println(classify(c));
}
}
Vous pourriez vous attendre à ce que ce programme imprime les chaînes Set, puis List et Unknown Collection, mais ce n'est pas le cas. Au lieu de cela, la chaîne Unknown Collection est imprimée trois fois. Pourquoi cela arrive-t-il? Étant donné que la méthode classify est surchargée, la méthode surchargée à appeler est sélectionnée au moment de la compilation . Pour les trois itérations de la boucle, le type de l’argument au moment de la compilation est le même : Collection<?> .
Parce que la sélection entre les méthodes surchargées est statique, tandis que la sélection entre les méthodes surchargées est dynamique . Lorsqu'une méthode surchargée est appelée, le type de l'objet à la compilation n'a aucun effet sur la méthode qui est exécutée ; la méthode surchargée "la plus spécifique" est toujours exécutée. Comparez cela à la surcharge, où le type d'exécution de l'objet n'a aucun effet sur la surcharge effectuée ; la sélection est effectuée au moment de la compilation , entièrement basée sur le type du paramètre au moment de la compilation.
Une stratégie sûre et conservatrice consiste à ne jamais exporter deux surcharges avec le même nombre d'arguments . Si une méthode utilise des paramètres variadiques, la stratégie conservatrice consiste à ne pas la surcharger du tout. Si ces restrictions sont respectées, le programmeur n'a aucun doute sur les surcharges qui s'appliquent à tout ensemble de paramètres réels. Ces restrictions ne sont pas très lourdes car il est toujours possible de donner des noms différents aux méthodes au lieu de les surcharger.
En résumé, ce n’est pas parce que vous pouvez surcharger une méthode que vous devriez le faire. En général, il est préférable d'éviter de surcharger les méthodes avec plusieurs signatures ayant le même nombre de paramètres . Dans certains cas, notamment lorsque des constructeurs sont impliqués, il peut ne pas être possible de suivre ces conseils. Dans ces cas, vous devez au moins éviter de transmettre le même ensemble de paramètres à différentes surcharges en ajoutant des transtypages. Si cela ne peut être évité, par exemple parce qu'une classe existante est en cours de mise à niveau pour implémenter une nouvelle interface, vous devez alors vous assurer que toutes les surcharges se comportent de la même manière lors du passage des mêmes arguments. Sans cela, le programmeur aura du mal à utiliser efficacement une méthode ou un constructeur surchargé ou à comprendre pourquoi cela ne fonctionne pas.
Utiliser les paramètres variadiques de manière judicieuse et judicieuse
Les méthodes à paramètres variables, formellement connues sous le nom de méthodes d'arité variable [JLS, 8.4.1], acceptent zéro ou plusieurs paramètres d'un type spécifié. Le mécanisme variadique crée d'abord un tableau dont la taille est le nombre d'arguments transmis sur le site d'appel, puis place les valeurs des arguments dans le tableau et enfin transmet le tableau à la méthode.
Soyez prudent lorsque vous utilisez des arguments variadiques dans des situations critiques en termes de performances. Chaque appel à une méthode variadique entraîne l'allocation et l'initialisation du tableau . Si vous déterminez empiriquement que vous ne pouvez pas vous permettre ce coût, mais que vous avez quand même besoin de la flexibilité des paramètres variadiques, alors il existe un modèle qui vous permet d'avoir le meilleur des deux mondes. En supposant que vous avez déterminé que 95 % des appels concernent des méthodes comportant trois arguments ou moins, déclarez cinq surcharges de cette méthode. Chaque méthode surchargée contient 0 à 3 paramètres normaux. Lorsque le nombre de paramètres dépasse 3, une méthode variadique est utilisée.
En résumé, les arguments variadiques sont utiles lorsque vous devez définir une méthode avec un nombre variable d'arguments. Ajoutez tous les arguments requis avant d'utiliser des arguments variadiques et soyez conscient des conséquences sur les performances de l'utilisation d'arguments variadiques.
Renvoie un tableau ou une collection vide, ne renvoie pas null
On prétend parfois qu’une valeur de retour nulle est préférable à une collection ou à un tableau vide car elle évite la surcharge liée à l’allocation d’un conteneur vide. Cet argument échoue sur deux points. Premièrement, vous ne devriez pas vous inquiéter des performances à ce niveau, sauf si les mesures montrent que l'allocation en question est la véritable cause du problème de performances. Deuxièmement, les collections et tableaux vides peuvent être renvoyés sans les allouer. S'il existe des preuves que l'allocation d'une collection vide nuit aux performances, l'allocation peut être évitée en renvoyant à plusieurs reprises la même collection vide immuable, car les objets immuables peuvent être librement partagés .
En résumé, ne renvoyez jamais null à la place d’un tableau ou d’une collection vide. Cela rend votre API plus difficile à utiliser, plus sujette aux erreurs et n'offre aucun avantage en termes de performances.
Retour facultatif avec sagesse et prudence
Avant Java 8, il existait deux approches, dont aucune n'était parfaite, pour écrire une méthode qui ne pouvait renvoyer aucune valeur dans un cas spécifique :
- Soit vous lancez une exception, mais les exceptions doivent être réservées aux conditions d'exception, et la levée d'exceptions coûte cher car la totalité de la trace de la pile est capturée lorsque l'exception est créée. Renvoyer null ne présente pas ces inconvénients, mais il a ses propres inconvénients.
- Soit renvoyer null (en supposant que le type de retour est un objet ou un type référence) ; si la méthode renvoie null, le client doit inclure un code de cas spécial pour gérer la possibilité d'un retour nul, à moins que le programmeur ne puisse prouver qu'un retour nul est impossible . Si le client néglige de vérifier les retours nuls et stocke la valeur de retour nulle dans une structure de données, il est possible qu'une NullPointerException soit levée à un moment donné dans le futur à un emplacement de code qui n'est pas pertinent pour ce problème.
Dans Java 8, il existe une troisième façon d'écrire des méthodes qui ne peuvent renvoyer aucune valeur. La classe Optionnel représente un conteneur immuable qui peut contenir une référence non nulle à T ou rien du tout. Un facultatif qui ne contient aucun contenu est appelé vide. Un facultatif non vide contenant une valeur est dit présent. Facultatif est essentiellement une collection immuable qui peut contenir au plus un élément. Facultatif n’implémente pas l’interface Collection, mais en principe c’est possible.
La méthodeOptional.of(value)accepte une valeur qui peut être nulle.Si null est transmis,un facultatif vide est renvoyé. Ne renvoyez jamais une valeur nulle à partir d’une méthode qui renvoie un Optionnel : cela va à l’encontre de l’objectif de la conception de Optionnel .
En résumé, si vous constatez que la méthode que vous avez écrite ne peut pas toujours renvoyer une valeur et que vous pensez qu'il est important que l'utilisateur de la méthode envisage cette possibilité à chaque fois qu'elle est appelée, alors vous devriez peut-être renvoyer une méthode facultative. Cependant, vous devez être conscient que le retour d'Optional a de réelles conséquences sur les performances ; pour les méthodes critiques en termes de performances, il est préférable de renvoyer null ou de lever une exception.
Rédiger des commentaires de documentation pour tous les éléments d'API exposés
Si une API doit être utilisable, elle doit être documentée. Traditionnellement, la documentation de l'API était générée manuellement et maintenir la synchronisation de la documentation avec le code était une corvée. L'environnement de programmation Java simplifie cette tâche à l'aide de l'utilitaire Javadoc. Javadoc utilise des commentaires de documentation spécialement formatés (souvent appelés commentaires de documentation) pour générer automatiquement la documentation de l'API à partir du code source.
Pour documenter correctement une API, chaque déclaration de classe, d'interface, de constructeur, de méthode et de propriété exportée doit être précédée d'un commentaire de documentation.
Il existe un principe général selon lequel les commentaires de la documentation doivent être lisibles à la fois dans le code source et dans la documentation générée.
Pour éviter toute confusion, deux membres ou constructeurs d'une classe ou d'une interface ne doivent pas avoir la même description récapitulative . Portez une attention particulière aux méthodes surchargées, pour lesquelles il est souvent naturel d'utiliser la même première phrase (mais ce n'est pas acceptable dans les commentaires de la documentation)
En bref, les commentaires sur la documentation constituent le moyen le meilleur et le plus efficace de documenter les API. Leur utilisation doit être considérée comme obligatoire pour tous les éléments API exportés. Utilisez un style cohérent qui respecte les conventions standard. N'oubliez pas que le HTML arbitraire est autorisé dans les commentaires de la documentation, mais que les métacaractères HTML doivent être échappés.
Minimiser la portée des variables locales
Cet élément est de nature similaire à « Réduire l’accessibilité des classes et des membres ». En minimisant la portée des variables locales, vous pouvez améliorer la lisibilité et la maintenabilité de votre code et réduire le risque d'erreurs .
Une technique puissante pour . Si les variables sont déclarées avant d'être utilisées, cela devient encore plus déroutant - et cela ajoute une autre distraction pour le lecteur essayant de comprendre le programme. Au moment où la variable est utilisée, le lecteur peut ne pas se souvenir du type ou de la valeur initiale de la variable.
Déclarer une variable locale trop tôt peut avoir pour conséquence que sa portée commence non seulement trop tôt mais aussi se termine trop tard. La portée d'une variable locale s'étend de l'emplacement où elle est déclarée jusqu'à la fin du bloc englobant. Si une variable est déclarée en dehors du bloc englobant dans lequel elle est utilisée, elle reste visible une fois que le programme a quitté le bloc englobant. Si une variable est accidentellement utilisée avant ou après sa zone d’utilisation prévue, les conséquences peuvent être catastrophiques.
Presque toutes les déclarations de variables locales doivent contenir un initialiseur . S'il n'y a pas encore suffisamment d'informations pour initialiser raisonnablement une variable, alors la déclaration doit être reportée jusqu'à ce qu'il soit jugé possible de le faire. Une exception à cette règle est l'instruction try-catch.
La technique ultime pour minimiser . Si vous combinez deux activités dans la même méthode, les variables locales liées à une activité peuvent se trouver dans la portée du code qui exécute l'autre activité. Pour éviter que cela ne se produise, divisez simplement la méthode en deux : une méthode pour chaque comportement.
La boucle for-each est meilleure que la boucle for traditionnelle
La boucle for-each (officiellement appelée « instruction for améliorée ») résout tous ces problèmes. Il élimine l'encombrement et les risques d'erreurs en masquant les itérateurs ou les variables d'index. Cependant, il existe trois situations courantes dans lesquelles vous ne pouvez pas utiliser une boucle for-each séparément :
- Filtrage destructif - Si vous devez parcourir la collection et supprimer une sélection spécifiée, vous devez utiliser un itérateur explicite pour que sa méthode de suppression puisse être appelée. Le parcours explicite peut généralement être évité en utilisant la méthode removeIf dans la classe Collection ajoutée dans Java 8.
- Conversion - Si vous devez parcourir une liste ou un tableau et remplacer tout ou partie des valeurs de ses éléments, vous avez besoin d'un itérateur de liste ou d'un index de tableau pour remplacer la valeur de l'élément.
- Itération parallèle - Si vous devez parcourir plusieurs collections en parallèle, vous devez contrôler explicitement les itérateurs ou les variables d'index afin que tous puissent être effectués simultanément (comme démontré par inadvertance dans l'exemple erroné de cartes et de dés ci-dessus)
Si vous vous trouvez dans l'une de ces situations, utilisez une boucle for traditionnelle et méfiez-vous des pièges mentionnés dans cette entrée.
En résumé, la boucle for-each offre des avantages incontestables par rapport aux boucles for traditionnelles en termes de clarté, de flexibilité et de prévention des erreurs, sans pénaliser les performances. Dans la mesure du possible, utilisez les boucles for-each de préférence aux boucles for.
Comprendre et utiliser les bibliothèques
En utilisant la bibliothèque standard, vous tirez parti des connaissances des experts qui l’ont écrite et de l’expérience de ceux qui l’ont déjà utilisée .
从 Java 7 开始,就不应该再使用 Random。在大多数情况下,选择的随机数生成器现在是 ThreadLocalRandom。 它能产生更高质量的随机数,而且速度非常快。在我的机器上,它比 Random 快 3.6 倍。对于 fork 连接池和并行流,使用 SplittableRandom。
使用这些库的第二个好处是,你不必浪费时间为那些与你的工作无关的问题编写专门的解决方案。如果你像大多 数程序员一样,那么你宁愿将时间花在应用程序上,而不是底层管道上。
使用标准库的第三个优点是,随着时间的推移,它们的性能会不断提高,而你无需付出任何努力。由于许多人使 用它们,而且它们是在行业标准基准中使用的,所以提供这些库的组织有很强的动机使它们运行得更快。
考虑到所有这些优点,使用库工具而不选择专门的实现似乎是合乎逻辑的,但许多程序员并不这样做。为什么不 呢?也许他们不知道库的存在。在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的。
有时,类库工具可能无法满足你的需求。你的需求越专门化,发生这种情况的可能性就越大。虽然你的第一个思 路应该是使用这些库,但是如果你已经了解了它们在某些领域提供的功能,而这些功能不能满足你的需求,那么可以 使用另一种实现。任何有限的库集所提供的功能总是存在漏洞。如果你在 Java 平台库中找不到你需要的东西,你的 下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源 Guava 库 [Guava]。如果你无法在任何适当的库中 找到所需的功能,你可能别无选择,只能自己实现它。
Bref, ne réinventez pas la roue. Si vous devez faire quelque chose qui semble assez courant, il existe probablement déjà un outil dans la bibliothèque qui fait ce que vous voulez. S'il est là, utilisez-le ; si vous ne le connaissez pas, vérifiez-le. En général, le code de la bibliothèque est probablement meilleur que le code que vous écririez vous-même et peut être amélioré au fil du temps. Cela ne reflète pas vos capacités en tant que programmeur. Les économies d'échelle imposent que le code de la bibliothèque reçoive bien plus d'attention que ce que la plupart des développeurs peuvent se permettre pour la même fonctionnalité.
Pour être précis, évitez d'utiliser float et double
Les types flotteur et double sont principalement utilisés dans les calculs scientifiques et les calculs techniques. Ils effectuent des opérations binaires en virgule flottante, un algorithme soigneusement conçu pour fournir rapidement des approximations précises sur une large plage. Cependant, ils ne fournissent pas de résultats précis et ne doivent pas être utilisés lorsque des résultats précis sont requis. Les types float et double sont particulièrement inadaptés aux calculs monétaires car il est impossible de représenter 0,1 (ou toute puissance négative de 10) exactement comme un float ou un double.
En résumé, n'utilisez pas de types float ou double pour tout calcul nécessitant une réponse exacte. Si vous souhaitez que le système gère les points décimaux et que les inconvénients et les coûts liés à la non-utilisation d'un type primitif ne vous dérangent pas, utilisez BigDecimal . Un autre avantage de l'utilisation de BigDecimal est qu'il vous donne un contrôle total sur l'arrondi et que vous pouvez choisir parmi huit modes d'arrondi lorsque vous effectuez des opérations nécessitant un arrondi. Ceci est très pratique si vous effectuez des calculs commerciaux en utilisant le comportement d'arrondi légal. Si les performances sont importantes, que cela ne vous dérange pas de gérer vous-même le point décimal et que le nombre n'est pas trop grand, utilisez un int ou un long. Si la valeur ne dépasse pas 9 décimales, vous pouvez utiliser int ; si elle ne dépasse pas 18 décimales, vous pouvez utiliser long. Si la quantité est susceptible de dépasser 18 chiffres, utilisez BigDecimal.
Les types de données de base sont meilleurs que les classes wrapper
Il existe trois différences principales entre les types de base et les types enveloppés. Premièrement, les types primitifs n’ont que leurs valeurs, tandis que les types wrapper ont des identités distinctes de leurs valeurs. En d’autres termes, deux instances de type wrapper peuvent avoir la même valeur et des identités différentes. Deuxièmement, le type de base n'a que des valeurs fonctionnelles complètes, tandis que chaque type wrapper a une valeur non fonctionnelle, qui est nulle, en plus de toutes les valeurs fonctionnelles du type de base correspondant. Enfin, les types de base sont plus efficaces en termes de temps et d'espace que les types packagés . Ces trois différences peuvent vous causer de réels problèmes si vous n’y faites pas attention.
En bref, chaque fois que vous avez le choix, vous devriez préférer utiliser les types de base plutôt que les types wrapper. Les types de base sont plus simples et plus rapides. Si vous devez utiliser des types wrapper, soyez prudent ! L'autoboxing réduit la verbosité liée à l'utilisation des types wrapper, mais pas les dangers. Lorsque votre programme utilise l'opérateur == pour comparer deux types encapsulés, il effectue une comparaison d'identité, ce qui n'est certainement pas ce que vous souhaitez. Lorsque votre programme effectue des calculs de types mixtes impliquant des types encapsulés et des types primitifs, il effectuera un déballage. Lorsque votre programme effectuera un déballage, une exception NullPointerException sera levée. Enfin, lorsque votre programme encadre des types primitifs, cela peut entraîner une création d'objets coûteuse et inutile.
Évitez d'utiliser des chaînes lorsque d'autres types sont plus appropriés
Les chaînes sont conçues pour représenter du texte, et elles le font très bien. Les chaînes étant très courantes et bien prises en charge par Java, il est naturel de les utiliser à d'autres fins que les scénarios pour lesquels elles sont destinées.
Les chaînes sont un mauvais substitut aux autres types de valeur. En général, s'il existe un type valeur approprié, qu'il s'agisse d'un type primitif ou d'une référence d'objet, vous devez l'utiliser ; s'il n'y en a pas, vous devez en écrire un.
Les chaînes sont un mauvais substitut aux types énumération. Comme indiqué au point 34, les constantes de type énumération conviennent mieux aux constantes de type énumération qu'aux chaînes.
Les chaînes sont un mauvais substitut aux types agrégés. Si une entité comporte plusieurs composants, c'est généralement une mauvaise idée de la représenter sous la forme d'une seule chaîne.
Exemple de code :
String compoundKey = className + "#" + i.next();
Cette méthode présente de nombreux inconvénients. Cela peut prêter à confusion si les caractères utilisés pour séparer les champs apparaissent dans l'un des champs. Pour accéder à des champs individuels, vous devez analyser la chaîne, ce qui est un processus lent, long et sujet aux erreurs.
En résumé, l'utilisation de chaînes pour représenter des objets doit être évitée lorsque de meilleurs types de données existent ou peuvent être écrits. Si elles sont mal utilisées, les chaînes sont plus lourdes, moins flexibles, plus lentes et plus sujettes aux erreurs que les autres types. Les types de chaînes couramment utilisés à mauvais escient incluent les types primitifs, les énumérations et les types agrégés.
Soyez conscient des problèmes de performances causés par la concaténation de chaînes
L'opérateur de concaténation de chaînes (+) est un moyen pratique de combiner plusieurs chaînes en une seule. C'est correct pour produire une seule ligne de sortie ou pour construire une représentation sous forme de chaîne d'un petit objet de taille fixe, mais cela n'est pas évolutif. La concaténation répétée de n chaînes à l’aide de l’opérateur de concaténation de chaînes prend n au carré. C'est une conséquence du fait que les chaînes sont immuables (Item-17). Lorsque deux chaînes sont concaténées, le contenu des deux chaînes est copié.
L'idée est simple : n'utilisez pas l'opérateur de concaténation de chaînes pour fusionner plusieurs chaînes, sauf si les performances n'ont pas d'importance . Sinon, utilisez la méthode append de StringBuilder. Vous pouvez également utiliser un tableau de caractères ou travailler avec une chaîne à la fois au lieu de les combiner.
Objets de référence via des interfaces
De manière générale, vous devez utiliser des interfaces plutôt que des classes pour référencer des objets. Si un type d'interface approprié existe, les paramètres, les valeurs de retour, les variables et les champs doivent être déclarés à l'aide du type d'interface . Le seul moment où vous avez réellement besoin de référencer la classe d'un objet, c'est lorsque vous le créez à l'aide d'un constructeur.
Si vous prenez l’habitude d’utiliser des interfaces comme types, vos programmes seront plus flexibles . Si vous décidez de changer d'implémentation, changez simplement le nom de la classe dans le constructeur (ou utilisez une autre usine statique).
Si aucune interface appropriée n'existe, il est parfaitement approprié d'utiliser une classe pour référencer l'objet . Par exemple, considérons les classes de valeurs telles que String et BigInteger. Les classes de valeurs sont rarement écrites en pensant à plusieurs implémentations. Ils sont généralement définitifs et ont rarement des interfaces correspondantes. Il est parfaitement adapté pour utiliser des classes de valeurs telles que des paramètres, des variables, des champs ou des types de retour.
Dans une application réelle, il devrait être évident si un objet donné possède une interface appropriée. Si tel est le cas, votre programme sera plus flexible et plus populaire si vous utilisez des interfaces pour référencer des objets. S'il n'existe pas d'interface appropriée, utilisez la classe de niveau inférieur dans la hiérarchie des classes qui fournit les fonctionnalités requises.
Interfaces sur réflexion
Le mécanisme de réflexion principal java.lang.reflect fournit un accès programmatique à n'importe quelle classe . Étant donné un objet Class, vous pouvez obtenir des instances Constructor, Method et Field, qui représentent respectivement le constructeur, la méthode et le champ de la classe représentée par l'instance Class. Ces objets fournissent un accès par programmation aux noms de membres de la classe, aux types de champs, aux signatures de méthodes, etc.
De plus, les instances Constructor, Method et Field vous permettent de manipuler leurs homologues sous-jacentes de manière réfléchie : en appelant des méthodes sur les instances Constructor, Method et Field, vous pouvez construire des instances de la classe sous-jacente, appeler des méthodes de la classe sous-jacente et accéder aux Fields. dans la classe sous-jacente. Par exemple, Method.invoke vous permet d'invoquer n'importe quelle méthode (sous réserve des contraintes de sécurité par défaut) sur n'importe quel objet de n'importe quelle classe. La réflexion permet à une classe d'utiliser une autre classe même si cette dernière n'existe pas lors de la compilation de la première . Cependant, cette capacité a un prix :
- Vous perdez tous les avantages de la vérification de type au moment de la compilation, y compris la vérification des exceptions . Si un programme tente d'appeler par réflexe une méthode inexistante ou inaccessible, il échouera au moment de l'exécution, sauf si vous prenez des précautions particulières.
- Le code requis pour effectuer un accès réflexif est lourd et long . C'est fastidieux à écrire et difficile à lire. Les performances sont réduites.
- Les appels de méthode réfléchis sont beaucoup plus lents que les appels de méthode normaux . Il est difficile de dire à quel point il est plus lent, car de nombreux facteurs entrent en jeu. Sur ma machine, la réflexion est 11 fois plus lente lors de l'appel d'une méthode qui ne prend aucun paramètre d'entrée et renvoie un int.
En utilisant la réflexion sous une forme très limitée, vous bénéficiez de nombreux avantages de la réflexion à une fraction du coût . Pour de nombreux programmes, ils doivent utiliser une classe qui n'est pas disponible au moment de la compilation, et il existe une interface ou une superclasse appropriée pour référencer la classe au moment de la compilation (voir l'élément 64 pour plus de détails). Si tel est le cas, vous pouvez créer des instances en utilisant la réflexion et y accéder normalement via leur interface ou superclasse .
En résumé, la réflexion est un outil puissant, nécessaire pour certaines tâches de programmation système complexes, mais elle présente de nombreuses lacunes. Si vous écrivez un programme qui doit gérer des classes inconnues au moment de la compilation, vous devez utiliser la réflexion uniquement pour instancier des objets autant que possible et accéder aux objets à l'aide d'interfaces ou de superclasses connues au moment de la compilation.
Utiliser les méthodes locales de manière judicieuse et judicieuse
La Java Native Interface (JNI) permet aux programmes Java d'appeler des méthodes natives, écrites dans des langages de programmation natifs tels que C ou C++. Historiquement, les méthodes natives ont eu trois utilisations principales. Ils donnent accès à des fonctionnalités spécifiques à la plateforme telles que les registres. Ils donnent accès aux bases de codes locales existantes, notamment aux données existantes. Enfin, une approche native peut améliorer les performances en écrivant des parties de l'application axées sur les performances dans le langage natif.
Pour améliorer les performances, il est rarement recommandé d'utiliser des méthodes natives .
Bref, réfléchissez-y à deux fois avant d'utiliser des méthodes natives. Il n’est généralement pas nécessaire de les utiliser pour améliorer les performances. Si vous devez utiliser des méthodes natives pour accéder aux ressources sous-jacentes ou aux bibliothèques natives, utilisez le moins de code natif possible et testez-le minutieusement . Une seule erreur dans le code natif peut interrompre l’ensemble de l’application.
Optimiser judicieusement et prudemment
Ne sacrifiez pas l’architecture sonore au profit des performances. Efforcez-vous d'écrire de bons programmes, pas des programmes rapides . Si un bon programme n'est pas assez rapide, son architecture permettra de l'optimiser. Les bons programmes incarnent le principe de dissimulation des informations : lorsque cela est possible, ils localisent les décisions de conception au sein de composants individuels, afin que les décisions individuelles puissent être modifiées sans affecter le reste du système.
Essayez d'éviter les décisions de conception qui limitent les performances . Les composants d'une conception difficiles à modifier sont ceux qui précisent les interactions entre les composants et avec le monde extérieur. Les principaux composants de conception sont les API, les protocoles de couche ligne et les formats de données persistants. Non seulement ces composants de conception sont difficiles, voire impossibles, à modifier après coup, mais ils peuvent tous imposer des limites significatives aux performances qu'un système peut atteindre.
Considérez les conséquences sur les performances des décisions de conception d'API . La conversion d'un type public en mutable peut nécessiter de nombreuses copies défensives inutiles (voir l'article 50 pour plus de détails). De même, l'utilisation de l'héritage dans une classe publique (où la composition serait appropriée) lie définitivement cette classe à sa superclasse, limitant artificiellement les performances des sous-classes (voir le point 18 pour plus de détails). Ce dernier exemple est que l'utilisation de classes d'implémentation plutôt que d'interfaces dans une API vous lie à une implémentation spécifique, même si une implémentation plus rapide pourrait être écrite à l'avenir.
Mesurez les performances avant . Un outil qui mérite une mention spéciale est jmh, qui n'est pas un profileur mais un framework de microbenchmarking qui offre une prévisibilité inégalée des performances du code Java.
En bref, ne travaillez pas dur pour écrire des programmes rapides, mais travaillez dur pour écrire de bons programmes ; la vitesse augmentera naturellement. Mais veillez à prendre en compte les performances lors de la conception de votre système, en particulier lors de la conception d'API, de protocoles de couche ligne et de formats de données persistants . Lorsque vous avez fini de construire votre système, mesurez ses performances. Si c'est assez rapide, c'est fait. Sinon, utilisez l'analyseur pour trouver la source du problème et optimiser les parties concernées du système. La première étape consiste à examiner le choix de l’algorithme : aucune optimisation de bas niveau ne peut compenser un mauvais choix d’algorithme. Répétez ce processus si nécessaire, en mesurant les performances après chaque changement, jusqu'à ce que vous soyez satisfait.
Suivez les conventions de commande largement reconnues
La plate-forme Java possède un ensemble bien établi de conventions de dénomination, dont beaucoup sont contenues dans « La spécification du langage Java » [JLS, 6.1]. En gros, les conventions de dénomination se répartissent en deux catégories : la typographie et la syntaxe.
Les noms des packages et des modules doivent être hiérarchiques, avec des points séparant les composants. Les composants doivent être constitués de lettres minuscules, les chiffres étant rarement utilisés. Le nom de tout package utilisé en dehors de votre organisation doit commencer par le nom de domaine Internet de votre organisation, les composants étant inversés, par exemple edu.cmu, com.google, org.e.
En résumé, internalisez les conventions de dénomination standard et utilisez-les comme caractéristiques sexuelles secondaires. Les conventions typographiques sont simples et largement sans ambiguïté ; les conventions grammaticales sont plus complexes et lâches. Pour citer "La spécification du langage Java" [JLS, 6.1], "Vous ne devriez pas suivre aveuglément ces conventions si un usage traditionnel de longue date exige qu'elles ne soient pas suivies." Il faut faire preuve de bon sens.
Utiliser des exceptions uniquement pour des situations exceptionnelles
Les exceptions ne doivent être utilisées que dans des situations inhabituelles ; elles ne doivent jamais être utilisées dans le flux normal du contrôle du programme.
Une API bien conçue ne doit pas forcer ses clients à utiliser des exceptions au nom d'un flux de contrôle normal. Si une classe possède des méthodes « dépendantes de l'état », c'est-à-dire des méthodes qui ne peuvent être appelées que dans des conditions imprévisibles spécifiques, la classe doit également avoir une méthode « test d'état » distincte, qui indique si la méthode liée à cet état peut être appelé . Par exemple, l'interface Iterator contient la méthode next liée à l'état et la méthode de test d'état correspondante hasNext. Cela permet d'utiliser le modèle standard d'itération sur une collection en utilisant une boucle for traditionnelle (et une boucle for-each, qui utilise la méthode hasNext en interne)
En résumé, les exceptions sont conçues et utilisées dans des situations inhabituelles. Ne les soumettez pas au flux de contrôle ordinaire et n'écrivez pas d'API qui les obligent à le faire.
Utilisez des exceptions vérifiables pour les situations récupérables et des exceptions d'exécution pour les erreurs de programmation.
Le langage de programmation Java propose trois éléments : les exceptions vérifiées, les exceptions d'exécution et les erreurs. Il existe une confusion parmi les programmeurs quant à savoir quel objet jetable est approprié pour quelle situation. Bien que cette décision ne soit pas toujours claire, certains principes généraux fournissent des orientations solides.
La règle générale principale pour décider d'utiliser des exceptions cochées ou non est que si l'appelant est censé être en mesure de reprendre raisonnablement le fonctionnement du programme, vous devez alors utiliser des exceptions cochées . En lançant une exception vérifiée, l'appelant est obligé de gérer l'exception dans une clause catch ou de la propager . Par conséquent, chaque exception vérifiée déclarée dans une méthode à lever est un indice potentiel pour l'utilisateur de l'API que la condition associée à l'exception est un résultat possible de l'appel de cette méthode.
Il existe deux types d'éléments pouvant être lancés non contrôlés : les exceptions d'exécution et les erreurs. Sur le plan comportemental, les deux sont équivalents : ce sont tous deux des objets jetables qui n'ont pas besoin et ne doivent pas être attrapés. Si un programme génère une exception ou une erreur non vérifiée, il s'agit souvent d'une situation irrécupérable et il est dangereux et inutile de continuer à exécuter le programme . Si le programme n'attrape pas un tel objet jetable, le thread en cours s'arrêtera et un message d'erreur approprié apparaîtra.
Utilisez des exceptions d'exécution pour indiquer les erreurs de programmation. La plupart des exceptions d'exécution représentent des violations de conditions préalables . La soi-disant violation des prémisses signifie que le client de l'API ne respecte pas l'accord établi par la spécification de l'API. Par exemple, la réservation pour l'accès au tableau spécifie que la valeur de l'index du tableau doit être comprise entre 0 et la longueur du tableau - 1. ArrayIndexOutOfBoundsException indique une violation de cette prémisse.
Bien que cela ne soit pas requis par le JLS (Java Language Spécification), par convention, les erreurs (Erreur) sont souvent réservées à l'utilisation par la JVM pour indiquer des ressources insuffisantes, des échecs de contraintes ou d'autres conditions qui empêchent le programme de continuer à s'exécuter. Puisqu’il s’agit déjà d’une gestion presque universellement acceptée, il n’est pas nécessaire d’implémenter de nouvelles sous-classes d’erreur. Par conséquent, tous les objets jetables non cochés que vous implémentez doivent être des sous-classes de RuntimeExceptiond (soit directement, soit indirectement). Non seulement vous ne devez pas définir de sous-classe d’Erreur, mais vous ne devez pas non plus lever d’exception AssertionError.
En bref, pour les situations récupérables, des exceptions vérifiées doivent être levées ; pour les erreurs de programme, des exceptions d'exécution doivent être levées. S'il n'est pas sûr de pouvoir le récupérer, il sera rejeté comme exception vérifiée. Ne définissez aucun type de lancement qui ne soit ni une exception vérifiée ni une exception d'exécution. Fournissez des méthodes sur les exceptions vérifiées pour faciliter la récupération du programme.
Évitez les exceptions vérifiées inutiles
Les programmeurs Java n'aiment pas les exceptions vérifiées, mais si elles sont utilisées correctement, elles peuvent améliorer les API et les programmes. La raison de l'absence de codes retour et d'exceptions non vérifiées est qu'ils obligent le programmeur à gérer des conditions exceptionnelles, améliorant ainsi considérablement la fiabilité. En d’autres termes, une utilisation excessive des exceptions vérifiées rendra l’API très peu pratique à utiliser. Si une méthode génère des exceptions vérifiées, le code appelant la méthode doit gérer ces exceptions dans un ou plusieurs blocs catch, ou il doit déclarer que ces exceptions sont levées et les laisser se propager. Quelle que soit la méthode utilisée, elle ajoute une charge qui ne peut être ignorée pour le programmeur. Ce fardeau est encore plus lourd dans Java 8, car les méthodes qui lèvent des exceptions vérifiées ne peuvent pas être utilisées directement dans Streams .
Dans l’ensemble, les exceptions vérifiées peuvent améliorer la lisibilité du programme lorsqu’elles sont utilisées avec prudence ; si elles sont utilisées de manière excessive, elles rendront l’API très pénible à utiliser. Si l'appelant ne peut pas se remettre de l'échec, une exception non vérifiée doit être levée. Si la récupération est possible et que vous souhaitez forcer l'appelant à gérer la condition exceptionnelle, il est préférable de renvoyer une valeur facultative. Les exceptions vérifiées doivent être levées si, et seulement si, elles ne fournissent pas suffisamment d'informations en cas d'échec.
Préférer les exceptions standards
Une différence majeure entre les programmeurs experts et les programmeurs moins expérimentés est que les experts s'efforcent d'atteindre, et atteignent souvent, un degré élevé de réutilisation du code . La réutilisation du code mérite d’être encouragée : c’est une règle générale et les exceptions ne font pas exception. La bibliothèque de classes de la plateforme Java fournit un ensemble de base d'exceptions non contrôlées, qui répondent aux exigences de levée d'exceptions de la plupart des API.
La réutilisation des exceptions standard présente plusieurs avantages. Le principal avantage est qu’elle rend l’API plus facile à apprendre et à utiliser car elle est cohérente avec les idiomes que les programmeurs connaissent déjà. Le deuxième avantage est que les programmes qui utilisent ces API seront plus lisibles car ils n'auront pas beaucoup d'exceptions que les programmeurs ne connaissent pas. Enfin (et non des moindres), moins de classes d'exception signifient une empreinte mémoire plus petite et moins de temps passé à charger ces classes.
Ne réutilisez pas directement Exception, RuntimeException, Throwable ou Error . Traitez ces classes comme des classes abstraites. Vous ne pouvez pas tester ces exceptions de manière fiable car ce sont des superclasses d’autres exceptions qu’une méthode peut lancer.
Le choix de l'exception à réutiliser n'est pas toujours précis car les "cas d'utilisation" du tableau ci-dessus ne s'excluent pas mutuellement. Par exemple, considérons un objet représentant un jeu de cartes. Supposons qu'il existe une méthode qui gère l'opération de distribution de cartes et que son paramètre soit le nombre de cartes à distribuer dans une main. Supposons que la valeur transmise par l'appelant dans ce paramètre est supérieure au nombre de cartes restantes dans l'ensemble du jeu. Cette situation peut être interprétée soit comme une IllegalArgumentException (la valeur du paramètre handSize est trop grande), soit comme une IllegalStateException (l'objet card contient trop peu de cartes). Dans ce cas, si aucune valeur de paramètre n'est disponible, une llegalStateException est levée, sinon une llegalArgumentException est levée.
Lève l'exception correspondant à l'abstraction
Si l’exception levée par une méthode n’est pas clairement liée à la tâche qu’elle exécute, cette situation peut prêter à confusion. Cela a tendance à se produire lorsqu'une méthode transmet une exception levée par une abstraction de niveau inférieur. En plus de dérouter les gens, cela « pollue » également l'API de niveau supérieur avec les détails de mise en œuvre. Si l'implémentation de haut niveau change dans les versions ultérieures, les exceptions qu'elle génère peuvent également changer, interrompant potentiellement les programmes clients existants.
Pour éviter ce problème, les implémentations de niveau supérieur doivent détecter les exceptions de bas niveau et lever des exceptions qui peuvent être interprétées en termes d'abstractions de haut niveau . Cette approche est appelée traduction d'exceptions, comme indiqué dans le code suivant :
/* Exception Translation */
try {
... /* Use lower-level abstraction to do our bidding */
} catch ( LowerLevelException e ) {
throw new HigherLevelException(...);
}
Une forme spéciale de traduction d'exceptions est appelée chaînage d'exceptions. Si les exceptions de bas niveau sont très utiles pour déboguer les problèmes qui provoquent des exceptions de haut niveau, le chaînage d'exceptions est approprié . L'exception de bas niveau (raison) est transmise à l'exception de haut niveau, et l'exception de haut niveau fournit une méthode d'accès (la méthode getCause de Throwable) pour obtenir l'exception de bas niveau :
// Exception Chaining
try {
... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
Le constructeur d'exception de haut niveau transmet la raison au super constructeur prenant en charge le chaînage, de sorte qu'elle sera finalement transmise à l'un des constructeurs de Throwable qui exécutent la chaîne d'exceptions, tel que Throwable(Throwable) :
/* Exception with chaining-aware constructor */
class HigherLevelException extends Exception {
HigherLevelException( Throwable cause ) {
super(cause);
}
}
Bien que la traduction des exceptions soit une amélioration par rapport à la transmission sans discernement des exceptions des couches inférieures, elle ne peut pas être utilisée de manière abusive . Si possible, une bonne pratique pour gérer les exceptions des méthodes de niveau inférieur consiste à s'assurer qu'elles s'exécuteront correctement avant d'appeler les méthodes de bas niveau, les empêchant ainsi de lever des exceptions. Parfois, vous pouvez vérifier la validité des paramètres d'une méthode de niveau supérieur avant de les transmettre à la méthode de niveau inférieur pour éviter que la méthode de niveau inférieur ne lève des exceptions.
Dans l'ensemble, si vous ne pouvez pas empêcher ou gérer les exceptions des couches inférieures, l'approche générale consiste à utiliser la traduction des exceptions, uniquement si la spécification de la méthode de la couche inférieure garantit que "toutes les exceptions qu'elle génère sont également appropriées pour les couches supérieures". Les exceptions peuvent être propagées des niveaux inférieurs aux niveaux supérieurs. Le chaînage d'exceptions offre la meilleure fonctionnalité pour les exceptions de haut niveau et de bas niveau : il permet de lancer des exceptions de haut niveau appropriées, tout en capturant les causes de bas niveau pour l'analyse des échecs.
Les exceptions levées par chaque méthode sont documentées
Décrire les exceptions levées par une méthode est une partie importante de la documentation requise pour l'utilisation correcte de cette méthode. Par conséquent, il est particulièrement important de prendre le temps de documenter soigneusement les exceptions levées par chaque méthode.
Déclarez toujours . Si une méthode publique peut lancer plusieurs classes d'exceptions, n'utilisez pas de "raccourci" pour déclarer qu'elle lance une superclasse de ces classes d'exceptions. Ne déclarez jamais une méthode publique pour "lancer directement une exception", ou pire encore, déclarez-la directement pour "lancer Throwable" . Ceci est un exemple très extrême. Une telle déclaration non seulement ne fournit au programmeur aucune indication sur "les exceptions que cette méthode peut générer", mais entrave également grandement l'utilisation de cette méthode, car elle masque en fait la possibilité que cette méthode puisse générer dans le même environnement d'exécution . toute autre exception . Une exception à ce conseil est que la méthode principale peut être déclarée en toute sécurité pour lancer une exception car elle n'est appelée que par la machine virtuelle.
Utilisez la balise @throws de Javadoc pour documenter chaque exception non vérifiée qu'une méthode peut lever, mais n'utilisez pas le mot-clé throws pour inclure des exceptions non vérifiées dans la déclaration de la méthode.
Si plusieurs méthodes d'une classe lèvent la même exception pour la même raison, il est acceptable de documenter l'exception dans un commentaire de documentation pour la classe, plutôt que de documenter chaque méthode individuellement. Un exemple courant est NullPointerException.
En résumé, documentez chaque exception pouvant être levée par chaque méthode que vous écrivez. Cela est vrai pour les exceptions non vérifiées et les exceptions vérifiées, ainsi que pour les méthodes abstraites et concrètes. Cette documentation doit utiliser la balise @throws dans les commentaires de la documentation. Fournissez une déclaration distincte pour chaque exception vérifiée dans la clause throws de la méthode, mais ne déclarez pas les exceptions non vérifiées. Sans documentation des exceptions pouvant être levées, il sera difficile, voire impossible, pour les autres d'utiliser efficacement vos classes et interfaces.
Inclure les informations de capture d'échec dans les détails
Lorsque le programme échoue en raison d'une exception non interceptée, le système imprimera automatiquement la trace de pile de l'exception. Incluez dans la trace de la pile la représentation sous forme de chaîne de l'exception, c'est-à-dire le résultat de l'appel à sa méthode toString. Il contient généralement le nom de la classe de l'exception, suivi d'un message détaillé. En règle générale, il s'agit simplement d'informations qu'un programmeur ou un ingénieur en fiabilité de site Web doit examiner lorsqu'il enquête sur la cause d'une panne logicielle. Si la panne ne peut pas être facilement reproduite, obtenir des informations complémentaires sera difficile, voire impossible. Par conséquent, il est particulièrement important que la méthode toString d’un type d’exception renvoie autant d’informations que possible sur la cause de l’échec. En d'autres termes, la représentation sous forme de chaîne de l'exception doit capturer l'échec pour faciliter une analyse ultérieure .
Pour détecter un échec, les détails de l'exception doivent inclure les valeurs de tous les paramètres et champs qui ont contribué à l'exception . Par exemple, les détails d'une exception IndexOutOfBoundsException doivent inclure la limite inférieure, la limite supérieure et la valeur d'index qui n'entre pas dans les limites. Ce message détaillé fournit de nombreuses informations sur l'échec.
Un conseil pour les informations sensibles en matière de sécurité. Étant donné que les traces de pile peuvent être vues par de nombreuses personnes pendant le processus de diagnostic et de résolution des problèmes logiciels, n'incluez jamais de mots de passe, de clés et d'informations similaires dans les messages détaillés .
Les détails des exceptions ne doivent pas être confondus avec les messages d'erreur au niveau de l'utilisateur, qui doivent être compréhensibles pour l'utilisateur final. Contrairement aux messages d'erreur au niveau de l'utilisateur, les représentations de chaînes d'exception sont principalement utilisées par les programmeurs ou les ingénieurs en fiabilité des sites Web pour analyser la cause de l'échec . Le contenu de l’information est donc bien plus important que sa lisibilité. Les messages d'erreur au niveau de l'utilisateur sont souvent localisés, tandis que les messages détaillés d'exception le sont rarement . (Internationalisation de la traduction)
Atomicité de défaillance garantie
Lorsqu'un objet lève une exception, nous nous attendons généralement à ce qu'il reste dans un état utilisable bien défini, même si l'échec se produit au milieu de l'exécution d'une opération. Ceci est particulièrement important pour les exceptions vérifiées, puisque l'appelant s'attend à pouvoir récupérer de ces exceptions. En général, un appel de méthode ayant échoué devrait laisser l'objet dans l'état dans lequel il se trouvait avant son appel . On dit que les méthodes possédant cette propriété ont une atomicité d’échec. (Bases des transactions).
Il existe plusieurs façons d'obtenir cet effet. Le moyen le plus simple consiste à concevoir un objet immuable (voir le point 17 pour plus de détails). Si l’objet est immuable, l’atomicité de l’échec est évidente. Si une opération échoue, elle peut empêcher la création de nouveaux objets, mais elle ne laissera jamais les objets existants dans un état incohérent car chaque objet est dans un état cohérent au moment de sa création. Il n'y aura aucun changement dans le futur.
Un moyen courant d'obtenir l'atomicité des échecs pour les méthodes qui effectuent des opérations sur des objets mutables consiste à vérifier la validité des paramètres avant d'effectuer l'opération. Cela permet de lever l'exception appropriée avant que l'état de l'objet ne soit modifié .
Une troisième façon d'obtenir l'atomicité des échecs consiste à effectuer une opération sur une copie temporaire de l'objet, puis à remplacer le contenu de l'objet par le résultat de la copie temporaire une fois l'opération terminée . Si les données sont stockées dans une structure de données temporaire, le processus de calcul sera plus rapide, il est donc naturel d'utiliser cette méthode. Par exemple, certaines fonctions de tri sauvegardent leur liste d'entrée dans un tableau avant d'effectuer le tri, afin de réduire la surcharge liée à l'accès aux éléments dans la boucle interne du tri. Ceci est effectué pour des raisons de performances, mais cela présente l'avantage supplémentaire de garantir que la liste d'entrée reste intacte même si le tri échoue.
La dernière façon d'obtenir l'atomicité des échecs, qui est beaucoup moins courante, consiste à écrire un code de récupération qui intercepte les échecs qui se produisent pendant l'opération et ramène l'objet à l'état où il était avant le début de l'opération . Cette approche est principalement utilisée pour les structures de données persistantes (basées sur disque).
En résumé, dans le cadre de la spécification d'une méthode, toute exception levée doit laisser l'objet dans l'état dans lequel il se trouvait avant l'appel de la méthode. Si cette règle n'est pas respectée, la documentation de l'API doit clairement indiquer dans quel état se trouvera l'objet. Malheureusement, une grande partie de la documentation API existante ne parvient pas à le faire.
N'ignorez pas les exceptions
Un bloc catch vide , qui est de vous forcer à gérer des situations d’exception. Ignorer les anomalies, c'est comme ignorer une alarme incendie.
Les exceptions peuvent être ignorées dans certains cas. Par exemple, lors de la fermeture de FileinputStream. Étant donné que vous n'avez pas modifié l'état du fichier, vous n'avez pas besoin d'effectuer d'actions de récupération et vous avez lu les informations requises dans le fichier. Vous n'avez donc pas besoin de mettre fin à l'opération en cours. Même dans ce cas, il est judicieux de consigner les exceptions, car vous pouvez rechercher leurs causes si elles se produisent fréquemment. Si vous choisissez d'ignorer l'exception, le bloc catch doit contenir un commentaire expliquant pourquoi cela est possible, et la variable doit être nommée ignorée :
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // Default: guaranteed sufficient for any map
try {
numColors = f.get( 1L, TimeUnit.SECONDS );
} catch ( TimeoutException | ExecutionException ignored ) {
// Use default: minimal coloring is desirable, not required
}
La gestion correcte des exceptions peut complètement éviter l’échec. Tant que l'exception se propage au monde extérieur, elle entraînera au moins un échec rapide du programme, préservant ainsi les informations qui peuvent aider à déboguer la condition d'échec.
Accès synchrone aux données mutables partagées
Le mot-clé synchronisé peut garantir qu'un seul thread peut exécuter une certaine méthode ou un certain bloc de code en même temps.
Sans synchronisation, les modifications apportées à un thread ne peuvent pas être vues par les autres threads. La synchronisation empêche non seulement un thread de voir un objet dans un état incohérent, mais garantit également que chaque thread qui entre dans une méthode synchronisée ou un bloc de code synchronisé peut voir les effets de toutes les modifications précédentes protégées par le même verrou.
Vous avez peut-être dit que pour améliorer les performances, vous devez éviter d'utiliser la synchronisation lors de la lecture ou de l'écriture de données atomiques. Ce conseil est très dangereux et erroné. Bien que la spécification du langage garantisse que les threads ne verront pas de valeurs arbitraires lors de la lecture de données atomiques, elle ne garantit pas que les valeurs écrites par un thread seront visibles par un autre thread. La synchronisation est nécessaire pour une communication fiable entre les threads et pour un accès mutuellement exclusif .
La meilleure façon d’éviter les problèmes évoqués dans cet article est de ne pas partager de données mutables. Soit partagez des données immuables (voir le point 17 pour plus de détails), soit ne les partagez pas du tout. En d'autres termes, limitez les données mutables à un seul thread .
En résumé, lorsque plusieurs threads partagent des données mutables, chaque thread lisant ou écrivant des données doit effectuer une synchronisation . Sans synchronisation, rien ne garantit que les modifications apportées par un thread seront connues par un autre thread. L’incapacité de synchroniser les données mutables partagées peut entraîner un échec de vivacité et un échec de sécurité du programme. De tels échecs sont difficiles à déboguer. Ils peuvent être intermittents et dépendants du temps, et le comportement du programme peut être fondamentalement différent selon les machines virtuelles. Si seule une communication interactive entre les threads est requise et qu'aucune exclusion mutuelle n'est requise, le modificateur volatile est une forme de synchronisation acceptable, mais son utilisation correcte peut nécessiter quelques astuces.
Évitez la sursynchronisation
Pour éviter les pannes de vivacité et de sécurité, n'abandonnez jamais le contrôle du client dans une méthode ou un bloc de code synchronisé. En d'autres termes, dans une zone synchronisée, n'appelez pas de méthodes conçues pour être remplacées ou fournies par le client sous la forme d'un objet fonction (voir l'élément 24 pour plus de détails). Du point de vue de la classe contenant la zone synchronisée, une telle méthode est étrangère. La classe n’a aucune idée de ce que fera la méthode et n’a aucun contrôle sur celle-ci. En fonction de l'effet de la méthode étrangère, son appel à partir d'une zone synchronisée peut provoquer une exception, un blocage ou une corruption des données.
En fait, il existe une meilleure façon de déplacer les appels de méthodes étrangères hors du bloc de code synchronisé. La bibliothèque de classes Java fournit une collection simultanée (collection simultanée), voir l'élément 81 pour plus de détails, appelée CopyOnWriteArrayList, spécialement personnalisée à cet effet. Ce CopyOnWriteArrayList est une variante d'ArrayList, qui implémente ici toutes les opérations d'écriture en recopiant l'intégralité du tableau sous-jacent. Puisque le tableau interne ne change jamais, l’itération ne nécessite aucun verrouillage et est très rapide . Les performances de CopyOnWriteArrayList seront grandement affectées si elle est utilisée de manière intensive, mais elle est bonne pour les listes d'observateurs car elles changent rarement et sont fréquemment itérées.
D'une manière générale, vous devez effectuer le moins de travail possible dans la zone de synchronisation . Obtenez le verrou, examinez les données partagées, transformez les données si nécessaire, puis libérez le verrou. Si vous devez effectuer une action qui prend du temps, vous devez essayer de déplacer l'action en dehors de la zone de synchronisation sans compromettre la sécurité des données partagées.
. À l'ère du multicœur, le coût réel de la sursynchronisation n'est pas le temps CPU passé à acquérir des verrous ; c'est l' opportunité perdue de parallélisme et la latence provoquée par la nécessité de garantir que chaque cœur a une vue cohérente de la mémoire . Un autre coût potentiel d'une synchronisation excessive est qu'elle limite la capacité de la machine virtuelle à optimiser l'exécution du code.
总而言之,为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法。更通俗地讲,要尽量将同步区 字段内部的工作量限制到少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今 这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这 么做,同时还应该将这个决定清楚地写到文档中。
Executor 、task 和 stream 优先于线程
为特殊的应用程序选择 executor service 是很有技巧的。
如果编写的是小程序,或者是轻量负载的服务器,使用 Executors.newCachedThreadPool 通常是个不错的选择,因为它不需要配置,并且一般情况下能够正确地完成工作。
但是对于大负载的服务器来说,缓存的线程池就不是很好的选择了!在缓存的线程池中,被提交的任务没有排成队 列,而是直接交给线程执行。如果没有线程可用,就创建一个新的线程。如果服务器负载得太重,以致它所有的 CPU 都完全被占用了,当有更多的任务时,就会创建更多的线程,这样只会使情况变得更糟。
因此,在大负载的产品服务 器中,最好使用 Executors.newFixedThreadPool ,它为你提供了一个包含固定线程数目的线程池,或者为了大限度 地控制它,就直接使用 ThreadPoolExecutor 类。
Non seulement vous devriez essayer de ne pas écrire votre propre file d’attente de travail, mais vous devriez également essayer de ne pas utiliser directement les threads. Lors de l'utilisation directe de threads, Thread agit à la fois comme une unité de travail et un mécanisme d'exécution. Dans Executor Framework, l'unité de travail et le mécanisme d'exécution sont séparés . L’abstraction clé est désormais l’unité de travail, appelée tâche. Il existe deux types de tâches : Runnable et son proche cousin Callable (elle est similaire à Runnable, mais elle renvoie une valeur et peut lever des exceptions arbitraires). Le mécanisme commun d’exécution des tâches est le service exécuteur. Si vous examinez le problème du point de vue de la tâche et laissez un service exécuteur effectuer la tâche à votre place, vous bénéficiez d'une grande flexibilité dans le choix de la stratégie d'exécution appropriée. Essentiellement, ce que fait Executor Framework est l'exécution, et ce que fait Collections Framework est l'agrégation.
Dans Java 7, Executor Framework a été étendu pour prendre en charge les tâches de fork-join, qui sont exécutées via un service d'exécution spécial appelé pool de fork-join. Une tâche fork-join est représentée par une instance de ForkJoinTask,qui peut être divisée en sous-tâches plus petites. Le thread contenant le ForkJoinPool doit non seulement traiter ces tâches, mais également "voler" les tâches d'un autre thread pour garantir que tous les threads restent occupés, ainsi amélioration de l'utilisation du processeur, amélioration du débit et réduction de la latence . L’écriture et le réglage des tâches de jointure par fork sont délicats. Les flux simultanés (voir l'article 48 pour plus de détails) sont écrits sur des pools de jointure fork, et nous pouvons profiter de leurs avantages en termes de performances avec peu d'effort, en supposant qu'ils soient adaptés à la tâche à accomplir.
Donner la priorité à l’utilisation d’outils de simultanéité plutôt qu’à l’attente et à la notification
Les outils les plus avancés de java.util.concurrent sont divisés en trois catégories : Executor Framework, Concurrent Collection et Synchronizer.
Les collections simultanées fournissent des implémentations simultanées hautes performances pour les interfaces de collection standard (telles que List, Queue et Map). Pour fournir une concurrence élevée, ces implémentations gèrent elles-mêmes la synchronisation en interne. Par conséquent, il est impossible d'exclure une activité concurrente d'une collection concurrente ; la verrouiller ne fait que ralentir le programme .
En plus de fournir une excellente simultanéité, ConcurrentHashMap est également très rapide. Sur ma machine, la méthode interne optimisée ci-dessus est plus de 6 fois plus rapide que String.intern (mais rappelez-vous, String.intern doit utiliser une sorte de référence faible pour éviter les fuites de mémoire au fil du temps). Les collections simultanées ont abouti à ce que les collections synchronisées soient pour la plupart obsolètes. Par exemple, ConcurrentHashMap doit être utilisé de préférence à Collections.synchronizedMap . Le simple remplacement des cartes synchrones par des cartes simultanées peut améliorer considérablement les performances des applications simultanées.
Un synchroniseur est un objet qui permet à un thread d'attendre un autre thread, lui permettant ainsi de coordonner ses actions. Les synchroniseurs couramment utilisés sont CountDownLatch et Semaphore. Les moins couramment utilisés sont CyclicBarrier et Exchanger. Un synchroniseur puissant est Phaser .
Un verrou de compte à rebours est une barrière ponctuelle qui permet à un ou plusieurs threads d'attendre qu'un ou plusieurs autres threads fassent quelque chose. Le seul constructeur de Count DownLatch prend un paramètre de type int. Ce paramètre int fait référence au nombre de fois que la méthode countDown doit être appelée sur le latch avant que tous les threads en attente ne soient traités.
En bref, utiliser directement la méthode wait et la méthode notify équivaut à programmer en "langage d'assemblage simultané", tandis que java.util.concurrent fournit un langage de niveau supérieur. Il y a peu, voire aucune, de raison d'utiliser les méthodes wait et notify dans le nouveau code . Si vous gérez du code qui utilise la méthode wait et la méthode notify, veillez à toujours appeler la méthode wait depuis une boucle while en utilisant le modèle standard. En général, la méthode notifyAll doit être utilisée de préférence à la méthode notify. Si vous utilisez la méthode de notification, veillez à garantir la vivacité du programme.
La documentation doit contenir des attributs de sécurité des threads
Le comportement d'une classe lorsque ses méthodes sont utilisées simultanément est une partie importante de son accord avec le client. Si vous ne documentez pas le comportement d'une classe à cet égard, ses utilisateurs seront obligés de faire des hypothèses. Si ces hypothèses sont fausses, le programme résultant peut manquer de synchronisation ou avoir une synchronisation excessive. Dans les deux cas, de graves erreurs peuvent en résulter.
Vous avez peut-être entendu dire que vous pouvez déterminer si une méthode est thread-safe en recherchant le modificateur synchronisé dans la documentation de la méthode. Cette vision est fausse à plusieurs égards. En fonctionnement normal, il y a une raison pour laquelle le modificateur de synchronisation n'est pas inclus dans la sortie Javadoc. La présence du modificateur synchronisé dans une déclaration de méthode est un détail d'implémentation et ne fait pas partie de son API. Cela n'indique pas de manière fiable qu'une méthode est thread-safe .
Pour permettre une utilisation simultanée sécurisée, une classe doit clairement documenter les niveaux de sécurité des threads qu'elle prend en charge .
En bref, chaque classe doit avoir une description soigneusement formulée ou documenter clairement ses attributs de sécurité des threads à l'aide d'annotations thread-safe . Le modificateur synchronisé n'a aucun effet dans le document. Une classe conditionnellement thread-safe doit enregistrer quelles séquences d’appels de méthode nécessitent une synchronisation externe et quels verrous doivent être acquis lors de l’exécution de ces séquences. Si vous écrivez une classe qui est inconditionnellement thread-safe, envisagez d'utiliser un objet de verrouillage privé au lieu d'une méthode synchronisée. Cela vous protégera des interférences de synchronisation provenant des clients et des sous-classes et vous offrira une plus grande flexibilité pour adopter des méthodes sophistiquées de contrôle de concurrence dans les versions ultérieures.
Utilisez l'initialisation paresseuse à bon escient et judicieusement
L'initialisation paresseuse consiste à retarder l'initialisation d'un champ jusqu'à ce que sa valeur soit nécessaire. Si la valeur n'est pas obligatoire, le champ n'est pas initialisé. Cette technique fonctionne à la fois pour les champs statiques et les champs d'instance. Bien que l'initialisation paresseuse soit avant tout une optimisation, elle peut également être utilisée pour briser les boucles nuisibles et les initialisations d'instances dans les classes.
L'initialisation paresseuse a également son utilité. Si un champ n’est accessible que sur un petit sous-ensemble d’instances de la classe et que l’initialisation du champ est coûteuse, une initialisation paresseuse peut en valoir la peine. La seule façon d’en être sûr est de mesurer les performances d’une classe avec et sans initialisation paresseuse.
Dans la plupart des cas, une initialisation régulière est préférable à une initialisation paresseuse .
Si vous utilisez une initialisation paresseuse au lieu d'une circularité d'initialisation, utilisez des accesseurs synchronisés , car ils constituent une alternative simple et claire :
// Lazy initialization of instance field - synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
Les deux idiomes (initialisation normale et initialisation paresseuse utilisant des accesseurs synchronisés) restent inchangés lorsqu'ils sont appliqués aux champs statiques, à l'exception de l'ajout du modificateur static aux déclarations de champ et d'accesseur.
Si vous devez utiliser une initialisation différée pour améliorer les performances des champs d'instance, utilisez le mode de double vérification. Ce modèle évite le coût de verrouillage lors de l'accès aux champs après l'initialisation .
总之,您应该正常初始化大多数字段,而不是延迟初始化。如果必须延迟初始化字段以实现性能目标或为了破坏 友有害的初始化循环,则使用适当的延迟初始化技术。对于字段,使用双重检查模式;对于静态字段,则应该使用 the lazy initialization holder class idiom。例如,可以容忍重复初始化的实例字段,您还可以考虑单检查模式。
不要依赖线程调度器
当许多线程可以运行时,线程调度器决定哪些线程可以运行以及运行多长时间。任何合理的操作系统都会尝试公 平地做出这个决定,但是策略可能会有所不同。因此,编写良好的程序不应该依赖于此策略的细节。任何依赖线程调 度器来保证正确性或性能的程序都可能是不可移植的。
编写健壮、响应快、可移植程序的佳方法是确保可运行线程的平均数量不显著大于处理器的数量。这使得线程 调度器几乎没有选择:它只运行可运行线程,直到它们不再可运行为止。即使在完全不同的线程调度策略下,程序的 行为也没有太大的变化。
保持可运行线程数量低的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。如果线程没有做有用 的工作,它们就不应该运行。 对于 Executor 框架(详见第 80 条),这意味着适当调整线程池的大小 [Goetz06, 8.2], 并保持任务短小(但不要太短),否则分派开销依然会损害性能。
总之,不要依赖线程调度器来判断程序的正确性。生成的程序既不健壮也不可移植。因此,不要依赖 Thread.yield 或线程优先级。这些工具只是对调度器的提示。线程优先级可以少量地用于提高已经工作的程序的 服务质量,但绝不应该用于「修复」几乎不能工作的程序。
优先选择Java序列化的替代方案
Un problème fondamental avec la sérialisation est qu'elle est trop ouverte aux attaques et difficile à protéger, et le problème continue de croître : désérialiser le graphe d'objets en appelant la méthode readObject sur ObjectInputStream. Cette méthode est essentiellement un constructeur magique qui peut être utilisé pour instancier presque n'importe quel type d'objet sur le chemin de classe, à condition que le type implémente l'interface Serialisable. Pendant le processus de désérialisation du flux d'octets, cette méthode peut exécuter du code de n'importe lequel de ces types, de sorte que tous ces types de code entrent dans la portée de l'attaque.
Les attaques peuvent impliquer des bibliothèques de la plateforme Java, des bibliothèques tierces (telles que la collection Apache Commons) et des classes dans l'application elle-même. Même si vous suivez tous les meilleurs conseils pertinents et réussissez à écrire des classes sérialisables qui ne sont pas vulnérables aux attaques, votre application peut toujours être vulnérable.
Lorsque vous désérialisez un flux d'octets auquel vous n'avez pas confiance, vous êtes vulnérable. Un bon moyen d'éviter l'exploitation de la sérialisation est de ne jamais rien désérialiser . Il n'y a aucune raison d'utiliser la sérialisation Java dans tout nouveau système que vous écrivez .
Si vous ne pouvez pas éviter complètement la sérialisation Java, peut-être parce que vous devez travailler dans un environnement système existant, la meilleure option consiste alors à ne jamais désérialiser les données non fiables .
En résumé, la sérialisation est dangereuse et doit être évitée. Si vous concevez un système à partir de zéro, vous pouvez utiliser des données structurées multiplateformes telles que JSON ou protobuf. Ne désérialisez pas les données non fiables. Si vous devez le faire, utilisez le filtrage de désérialisation d'objet, mais sachez qu'il n'est pas garanti qu'il bloque toutes les attaques. Évitez d'écrire des classes sérialisables. Si vous devez le faire, soyez très prudent.
Implémentez Serialisable avec prudence
Rendre les instances d'une classe sérialisables est très simple, il suffit d'implémenter l'interface Serialisable. Parce que c'est si simple à réaliser, il existe une idée fausse répandue selon laquelle la sérialisation nécessite très peu d'efforts de la part du programmeur. La réalité est bien plus complexe. Même si le coût immédiat de rendre une classe sérialisable est négligeable, le coût à long terme est souvent énorme.
L'un des coûts majeurs de l'implémentation de l'interface Serialisable est qu'elle réduit la flexibilité nécessaire pour modifier l'implémentation de la classe une fois qu'elle est publiée.
Le deuxième coût de la mise en œuvre de l'interface Serialisable est qu'elle augmente le risque de bugs et de vulnérabilités de sécurité .
Le troisième coût de la mise en œuvre de l'interface Serialisable est qu'elle augmente la charge de test associée à la publication de nouvelles versions de la classe.
实现 Serializable 接口并不是一个轻松的决定。 如果一个类要参与一个框架,该框架依赖于 Java 序列化来进行 对象传输或持久化,这对于类来说实现 Serializable 接口就是非常重要的。
Les classes conçues pour l'héritage sont rarement adaptées à l'implémentation de l'interface Serialisable, et les interfaces sont rarement adaptées à son extension. La violation de cette règle impose une charge importante à quiconque étend la classe ou implémente l'interface.
Les classes internes . Ils utilisent des champs synthétiques générés par le compilateur pour stocker les références à l'instance englobante et pour stocker les valeurs des variables locales de l'instance englobante. La correspondance entre ces champs et la définition de classe est la même que si les noms des classes anonymes et des classes partielles n'étaient pas spécifiés. Par conséquent, la forme de sérialisation par défaut des classes internes n'est pas définie. Cependant, les classes membres statiques peuvent implémenter l'interface Serialisable.
Pensez à utiliser un formulaire de sérialisation personnalisé
Lorsque vous écrivez des cours dans un délai serré, vous devez généralement vous concentrer sur la conception d'une API bien conçue. Parfois, cela signifie publier une implémentation « unique » dont vous savez qu'elle sera remplacée dans une version ultérieure. Normalement, ce n'est pas un problème, mais si votre classe implémente l'interface Serialisable et utilise la forme de sérialisation par défaut, vous ne vous débarrasserez jamais complètement de cette implémentation "ponctuelle" . Cela affectera toujours le formulaire sérialisé. Ce n’est pas seulement une question théorique.
N'acceptez pas le formulaire de sérialisation par défaut sans . L'acceptation du formulaire de sérialisation par défaut doit être une décision justifiée du point de vue de la flexibilité, des performances et de l'exactitude. En général, lors de la conception d'un formulaire de sérialisation personnalisé, vous ne devez accepter le formulaire de sérialisation par défaut que s'il est sensiblement identique à l'encodage sélectionné par le formulaire de sérialisation par défaut.
Quelle que soit la forme de sérialisation que vous choisissez, déclarez un UID de version série explicite dans chaque classe sérialisable que vous écrivez . Cela élimine les UID de version série en tant que source potentielle d'incompatibilités (voir l'article 86 pour plus de détails). Cela permet également d'obtenir un petit avantage en termes de performances. Si un UID de version série n’est pas fourni, des calculs coûteux doivent être effectués pour en générer un au moment de l’exécution.
En résumé, si vous avez décidé qu'une classe doit être sérialisable, réfléchissez bien à ce que devrait être le formulaire de sérialisation. N'utilisez le formulaire de sérialisation par défaut que lorsque l'état logique de l'objet est raisonnablement décrit ; sinon, concevez un formulaire de sérialisation personnalisé adapté à la description de l'objet. La conception de la forme sérialisée d'une classe devrait prendre autant de temps que la conception de la méthode exportée, et les deux doivent être traitées avec prudence (voir le point 51 pour plus de détails). Tout comme les méthodes exportées ne peuvent pas être supprimées des versions futures, les champs ne peuvent pas être supprimés du formulaire sérialisé ; ils doivent être enregistrés pour toujours pour garantir la compatibilité de la sérialisation. Choisir la mauvaise forme de sérialisation peut avoir un impact négatif permanent sur la complexité et les performances de votre classe.
Écrivez la méthode readObject de manière protectrice
En résumé, lorsque vous écrivez la méthode readObject, pensez comme ceci : vous écrivez un constructeur public, et quel que soit le flux d'octets qui lui est transmis, il doit produire une instance valide. Ne présumez pas que ce flux d’octets représente nécessairement une instance sérialisée réelle. Bien que dans les exemples de cette entrée, la classe utilise le formulaire de sérialisation par défaut, tous les problèmes possibles abordés s'appliquent également aux classes avec des formulaires de sérialisation personnalisés. Vous trouverez ci-dessous quelques directives sous forme de résumé qui vous aideront à écrire des méthodes readObject plus robustes.
- Les champs de référence d'objet dans une classe doivent être conservés en tant que propriétés privées et chaque objet de ces champs doit être copié de manière protectrice. Les composants mutables dans les classes immuables entrent dans cette catégorie
- Pour toute contrainte, une InvalidObjectException est levée si la vérification échoue. Ces contrôles doivent suivre toutes les copies de protection.
- Si l'intégralité du graphe d'objet doit être validé après avoir été désérialisé, vous devez utiliser l'interface ObjectInputValidation (non abordée dans ce livre).
- Qu'il s'agisse d'une méthode directe ou indirecte, n'appelez aucune méthode substituable dans la classe.
Par exemple, le contrôle, l'énumération est meilleure que readResolve
En fait, si vous comptez sur readResolve pour le contrôle d'instance, tous les champs d'instance avec des types de référence d'objet doivent être déclarés transitoires.
L'accessibilité de readResolve . Si vous placez la méthode readResolve sur une classe finale, elle doit être privée. Si vous placez la méthode readResolve sur une classe non finale, vous devez soigneusement réfléchir à son accessibilité. S’il est privé, il ne s’appliquera à aucune sous-classe. S'il est privé au niveau du package, il s'applique aux sous-classes du même package. Si elle est protégée ou publique et que la sous-classe ne la remplace pas, la désérialisation de la sous-classe sérialisée produira une instance de superclasse, ce qui peut entraîner une ClassCastException.
En résumé, les types énumérés doivent être utilisés autant que possible pour implémenter des contraintes contrôlées par l'instance . Si cela n'est pas possible et que vous avez besoin d'une classe à la fois sérialisable et contrôlée par une instance, vous devez fournir une méthode readResolve et vous assurer que tous les champs instanciés de la classe sont de types basiques ou transitoires.
Envisagez d'utiliser des proxys de sérialisation au lieu d'instances de sérialisation
L'implémentation de l'interface Serialisable augmente le risque d'erreurs et de problèmes de sécurité car elle permet de créer des instances à l'aide de mécanismes extérieurs au langage plutôt qu'à l'aide de constructeurs ordinaires. Il existe cependant un moyen de réduire considérablement ces risques. Il s'agit du modèle de proxy de sérialisation.
Le modèle de proxy de sérialisation est assez simple. Tout d’abord, concevez une classe imbriquée statique privée pour la classe sérialisable qui représente avec précision l’état logique de la classe englobante. Cette classe imbriquée est appelée proxy de sérialisation et elle doit avoir un constructeur distinct dont le type de paramètre est la classe englobante. Ce constructeur copie simplement les données de ses arguments : il n'a pas besoin d'effectuer de contrôles de cohérence ni de copies de protection. Du point de vue de la conception, la forme de sérialisation par défaut de l'agent de sérialisation est la meilleure forme de sérialisation de la classe englobante. La classe périphérique et son proxy série doivent déclarer qu'ils implémentent l'interface Serialisable.
Le modèle de proxy de sérialisation Il n'est pas compatible avec les classes pouvant être étendues par les clients. Ce n'est pas non plus compatible avec certaines classes qui contiennent des boucles dans le graphe d'objet : si vous essayez d'appeler une méthode sur un objet depuis la méthode readResovle du proxy de sérialisation de cet objet, vous obtiendrez une ClassCastException car vous n'avez pas l'objet. pourtant, juste son proxy de sérialisation.
Enfin, les fonctionnalités et la sécurité améliorées fournies par le modèle de proxy sérialisé n'ont pas un prix. Sur ma machine, la surcharge liée à la sérialisation et à la désérialisation des instances Period via le proxy de sérialisation est 14 % plus élevée que l'utilisation d'une copie de protection.
En résumé, lorsque vous constatez que vous devez écrire la méthode readObject ou writeObject sur une classe qui ne peut pas être étendue par le client, vous devez envisager d'utiliser le modèle de proxy de sérialisation. Ce modèle est le moyen simple de sérialiser de manière robuste des objets avec des contraintes importantes.