Pour profiter de la large gamme de méthodes de requête incluses dans Java.util.stream
de Jdk 8, je suis tenté de concevoir des modèles de domaine où les getters de relation avec *
la multiplicité (avec zéro ou plusieurs instances) renvoie un Stream<T>
, au lieu d'un Iterable<T>
ou Iterator<T>
.
Je doute que des frais généraux supplémentaires soient encourus par le Stream<T>
par rapport au Iterator<T>
?
Donc, y a-t-il un inconvénient à compromettre mon modèle de domaine avec un Stream<T>
?
Ou à la place, dois-je toujours retourner un Iterator<T>
ou Iterable<T>
, et laisser à l'utilisateur final la décision de choisir d'utiliser ou non un flux en convertissant cet itérateur avec le StreamUtils
?
Remarque que renvoyer un Collection
n'est pas une option valide car dans ce cas la plupart des relations sont paresseuses et de taille inconnue.
Il y a beaucoup de conseils sur les performances ici, mais malheureusement, il s'agit en grande partie de conjectures, et peu d'entre elles indiquent les véritables considérations de performances.
@Holger il a raison en soulignant que nous devrions résister à la tendance apparemment écrasante de laisser la queue de performance remuer le chien de conception de l'API.
Bien qu'il existe un nombre considérable de considérations qui peuvent rendre un flux plus lent que, identique ou plus rapide que toute autre forme de traversée dans un cas donné, certains facteurs indiquent que les flux ont un avantage en termes de performances là où il compte - ensembles de données.
Il y a une surcharge de démarrage fixe supplémentaire pour créer un Stream
par rapport à créer un Iterator
- quelques autres objets avant de commencer le calcul. Si votre ensemble de données est volumineux, cela n'a pas d'importance; c'est un petit coût de démarrage amorti sur beaucoup de calculs. (Et si votre ensemble de données est petit, cela n'a probablement pas d'importance non plus - car si votre programme fonctionne sur de petits ensembles de données, les performances ne sont généralement pas votre préoccupation n ° 1 non plus.) Où cela est-ce que importe quand on va en parallèle; tout le temps consacré à la mise en place du pipeline entre dans la fraction sérielle de la loi d'Amdahl; si vous regardez l'implémentation, nous travaillons dur pour réduire le décompte des objets pendant la configuration du flux, mais je serais heureux de trouver des moyens de le réduire car cela a un effet direct sur la taille de l'ensemble de données seuil de rentabilité où le parallèle commence à gagner séquentiel.
Mais, plus important que le coût de démarrage fixe est le coût d'accès par élément. Ici, les streams gagnent réellement - et gagnent souvent gros - ce que certains peuvent trouver surprenant. (Dans nos tests de performances, nous voyons régulièrement des pipelines de flux qui peuvent surpasser leurs homologues for-loop sur Collection
homologues.) Et, il y a une explication simple à cela: Spliterator
a un accès par élément fondamentalement inférieur coûte que Iterator
, même séquentiellement. Il y a plusieurs raisons à cela.
Le protocole Iterator est fondamentalement moins efficace. Il nécessite d'appeler deux méthodes pour obtenir chaque élément. De plus, parce que les itérateurs doivent être robustes à des choses comme appeler next()
sans hasNext()
ou hasNext()
plusieurs fois sans next()
, ces deux méthodes doivent généralement faire un codage défensif (et généralement plus d'état et de branchement), ce qui ajoute à l'inefficacité. D'un autre côté, même la manière lente de traverser un séparateur (tryAdvance
) n'a pas cette charge. (C'est encore pire pour les structures de données simultanées, car la dualité next
/hasNext
est fondamentalement racée et les implémentations Iterator
doivent faire plus de travail pour se défendre contre les modifications simultanées que Spliterator
implémentations.)
Spliterator
offre en outre une itération "accélérée" - forEachRemaining
- qui peut être utilisée la plupart du temps (réduction, forEach), réduisant encore la surcharge du code d'itération qui assure l'accès aux internes de la structure de données. Cela a également tendance à très bien s'aligner, ce qui à son tour augmente l'efficacité d'autres optimisations telles que le mouvement de code, l'élimination de la vérification des limites, etc.
De plus, la traversée via Spliterator
a tendance à avoir beaucoup moins d'écritures de tas qu'avec Iterator
. Avec Iterator
, chaque élément provoque une ou plusieurs écritures de tas (à moins que Iterator
puisse être scalarisé via l'analyse d'échappement et ses champs hissés dans des registres.) Entre autres problèmes, cela provoque une activité de marque de carte GC, conduisant à la contention de la ligne de cache pour les marques de carte. D'un autre côté, Spliterators
ont tendance à avoir moins d'état, et les implémentations de force industrielle forEachRemaining
ont tendance à différer l'écriture de tout dans le tas jusqu'à la fin de la traversée, au lieu de stocker son état d'itération dans les sections locales qui mappent naturellement sur les registres, ce qui réduit l'activité du bus mémoire.
Résumé: ne vous inquiétez pas, soyez heureux. Spliterator
est un meilleur Iterator
, même sans parallélisme. (Ils sont également généralement plus faciles à écrire et plus difficiles à se tromper.)
Comparons l'opération courante d'itération sur tous les éléments, en supposant que la source est un ArrayList
. Ensuite, il existe trois méthodes standard pour y parvenir:
final E[] elementData = (E[]) this.elementData; final int size = this.size; for (int i=0; modCount == expectedModCount && i < size; i++) { action.accept(elementData[i]); }
final Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) { throw new ConcurrentModificationException(); } while (i != size && modCount == expectedModCount) { consumer.accept((E) elementData[i++]); }
Stream.forEach
Qui finira par appeler Spliterator.forEachRemaining
if ((i = index) >= 0 && (index = hi) <= a.length) { for (; i < hi; ++i) { @SuppressWarnings("unchecked") E e = (E) a[i]; action.accept(e); } if (lst.modCount == mc) return; }
Comme vous pouvez le voir, la boucle interne du code d'implémentation, où ces opérations se terminent, est fondamentalement la même, itérant sur les indices et lisant directement le tableau et passant l'élément au Consumer
.
Des choses similaires s'appliquent à toutes les collections standard du JRE, toutes ont des implémentations adaptées pour toutes les façons de le faire, même si vous utilisez un wrapper en lecture seule. Dans ce dernier cas, l'API Stream
gagnerait même légèrement, Collection.forEach
Doit être appelé sur la vue en lecture seule afin de déléguer à forEach
de la collection d'origine. De même, l'itérateur doit être encapsulé pour se protéger contre les tentatives d'invocation de la méthode remove()
. En revanche, spliterator()
peut renvoyer directement le Spliterator
de la collection d'origine car il ne prend pas en charge les modifications. Ainsi, le flux d'une vue en lecture seule est exactement le même que le flux de la collection d'origine.
Bien que toutes ces différences soient à peine perceptibles lors de la mesure des performances de la vie réelle, comme indiqué, la boucle interne , qui est la chose la plus pertinente pour les performances, est la même dans tous les cas.
La question est de savoir quelle conclusion en tirer. Vous pouvez toujours renvoyer une vue wrapper en lecture seule à la collection d'origine, car l'appelant peut toujours appeler stream().forEach(…)
pour itérer directement dans le contexte de la collection d'origine.
Étant donné que les performances ne sont pas vraiment différentes, vous devriez plutôt vous concentrer sur la conception de niveau supérieur, comme indiqué dans "Dois-je retourner une collection ou un flux?"