web-dev-qa-db-fra.com

Si les chaînes sont immuables dans .NET, alors pourquoi Substring prend-il du temps O(n))?

Étant donné que les chaînes sont immuables dans .NET, je me demande pourquoi elles ont été conçues pour que string.Substring() prenne un temps O (substring.Length) Au lieu de O(1)?

c.-à-d. quels ont été les compromis, le cas échéant?

443
Mehrdad

MISE À JOUR: J'ai tellement aimé cette question que je viens de la bloguer. Voir Chaînes, immuabilité et persistance


La réponse courte est: O (n) est O(1) si n ne grossit pas. La plupart des gens extrayez de minuscules sous-chaînes à partir de minuscules chaînes. La complexité croissante est donc asymptotique complètement sans importance.

La réponse longue est:

Une structure de données immuable construite de telle sorte que les opérations sur une instance permettent de réutiliser la mémoire de l'original avec seulement une petite quantité (généralement O(1) ou O (lg n)) de copie ou nouvelle allocation est appelée structure de données immuable "persistante". Les chaînes en .NET sont immuables; votre question est essentiellement "pourquoi ne sont-elles pas persistantes"?

Parce que lorsque vous regardez des opérations qui sont généralement effectuées sur des chaînes dans des programmes .NET, il est de toute manière pertinente à peine pire du tout simplement nouvelle chaîne. Le coût et la difficulté de la création d'une structure de données persistante complexe ne sont pas rentables.

Les gens utilisent généralement "sous-chaîne" pour extraire une chaîne courte - disons dix ou vingt caractères - sur une chaîne un peu plus longue - peut-être quelques centaines de caractères. Vous avez une ligne de texte dans un fichier séparé par des virgules et vous souhaitez extraire le troisième champ, qui est un nom de famille. La ligne comptera peut-être quelques centaines de caractères, le nom sera une douzaine. L'attribution de chaînes et la copie en mémoire de cinquante octets est étonnamment rapide sur du matériel moderne. Créer une nouvelle structure de données consistant en un pointeur vers le milieu d'une chaîne existante et une longueur vaut aussi étonnamment vite n'a aucune pertinence; "assez rapide" est par définition assez rapide.

Les sous-chaînes extraites sont généralement de petite taille et de courte durée de vie. le ramasse-miettes va bientôt les récupérer, et ils n'ont pas pris beaucoup de place sur le tas en premier lieu. Utiliser une stratégie persistante qui encourage la réutilisation de la plus grande partie de la mémoire n’est donc pas une victoire; tout ce que vous avez fait est de faire ralentir votre ramasse-miettes car il doit maintenant se préoccuper de la gestion des pointeurs intérieurs.

Si les opérations de sous-chaîne que les utilisateurs effectuaient généralement sur les chaînes étaient complètement différentes, il serait logique d'adopter une approche persistante. Si les utilisateurs avaient généralement des chaînes de plusieurs millions de caractères et extrayaient des milliers de sous-chaînes se chevauchant dont la taille variait de cent mille caractères et que ces sous-chaînes vivaient longtemps sur le tas, il serait alors parfaitement logique de choisir une sous-chaîne persistante. approche; il serait inutile et stupide de ne pas le faire. Mais la plupart des programmeurs sectoriels ne font rien, même vaguement, comme ce genre de choses . .NET n'est pas une plate-forme adaptée aux besoins du projet du génome humain; Les programmeurs d’analyse d’ADN doivent résoudre chaque jour les problèmes liés à ces caractéristiques d’utilisation des chaînes; les chances sont bonnes que vous ne le faites pas. Les rares qui construisent leurs propres structures de données persistantes qui correspondent étroitement aux scénarios d'utilisation de leurs.

Par exemple, mon équipe écrit des programmes qui effectuent une analyse à la volée de C # et VB au fur et à mesure que vous le tapez. Certains de ces fichiers de code sont énormes et donc nous ne pouvons pas faire O(n) manipulation de chaînes pour extraire des sous-chaînes, insérer ou supprimer des caractères. Nous avons construit un ensemble de structures de données immuables persistantes permettant de représenter les modifications dans un tampon de texte qui nous le permettent. pour réutiliser rapidement et efficacement la majeure partie des données de chaîne existantes et les analyses lexicales et syntaxiques existantes lors d'une édition typique. C’était un problème difficile à résoudre et sa solution était étroitement adaptée au domaine spécifique de C # et à l’édition de code VB). Il serait irréaliste d’attendre que le type de chaîne intégré résolve ce problème nous.

417
Eric Lippert

Précisément parce que Les chaînes sont immuables, .Substring Doit faire une copie d'au moins une partie de la chaîne d'origine. Faire une copie de n octets devrait prendre O(n) temps).

Comment pensez-vous que vous copieriez un tas d'octets dans constant temps?


EDIT: Mehrdad suggère de ne pas copier la chaîne, mais de garder une référence à une partie de celle-ci.

Considérez en .Net, une chaîne de plusieurs mégaoctets sur laquelle quelqu'un appelle .SubString(n, n+3) (pour tout n situé au milieu de la chaîne).

Maintenant, la chaîne ENTIRE ne peut pas être Garbage Collected simplement parce qu'une référence conserve 4 caractères? Cela semble être une perte d'espace ridicule.

En outre, le suivi des références aux sous-chaînes (qui peuvent même figurer à l'intérieur des sous-chaînes) et le fait de copier à des moments optimaux pour éviter de compromettre le GC (comme décrit ci-dessus) font du concept un cauchemar. Il est beaucoup plus simple et plus fiable de copier sur .SubString Et de conserver le modèle immuable.


EDIT: Voici un bonne petite lecture sur le danger de garder des références aux sous-chaînes au sein de chaînes plus grandes.

119
abelenky

Java (par opposition à .NET) fournit deux façons de faire Substring(), vous pouvez décider si vous souhaitez conserver uniquement une référence ou copier une sous-chaîne entière vers un nouvel emplacement de mémoire.

Le simple .substring(...) partage le tableau char utilisé en interne avec l'objet String d'origine, que vous pouvez ensuite copier avec new String(...) dans un nouveau tableau, si nécessaire (pour éviter toute gêne ramasse-miettes de l'original).

Je pense que ce type de flexibilité est la meilleure option pour un développeur.

33
sll

Java faisait référence à de plus grandes chaînes, mais:

Java a changé son comportement en en copiant , pour éviter de perdre de la mémoire.

Je pense cependant que cela peut être amélioré: pourquoi ne pas simplement faire la copie de manière conditionnelle?

Si la sous-chaîne est au moins la moitié de la taille du parent, on peut référencer le parent. Sinon, on peut simplement en faire une copie. Cela évite de perdre beaucoup de mémoire tout en offrant un avantage significatif.

12
Mehrdad

Aucune des réponses ici abordé "le problème de bracketing", ce qui veut dire que les chaînes dans .NET sont représentées comme une combinaison d'un BStr (la longueur stockée en mémoire "avant" le pointeur) et un CStr (la chaîne se termine par un '\ 0').

La chaîne "Hello there" est donc représentée par

0B 00 00 00 48 00 65 00 6C 00 6F 00 20 00 74 00 68 00 65 00 72 00 65 00 00 00

(Si assigné à un char* dans une instruction fixed-, le pointeur pointerait sur 0x48.)

Cette structure permet une recherche rapide de la longueur d'une chaîne (utile dans de nombreux contextes) et permet de transmettre le pointeur dans une API P/Invoke to Win32 (ou autre) qui attend une chaîne terminée par un caractère null.

Quand vous faites Substring(0, 5) la règle "oh, mais j'ai promis qu'il y aurait un caractère nul après le dernier caractère" indique que vous devez faire une copie. Même si vous aviez la sous-chaîne à la fin, il n'y aurait pas de place pour mettre la longueur sans corrompre les autres variables.


Parfois, cependant, vous voulez vraiment parler du "milieu de la chaîne" et vous ne vous souciez pas nécessairement du comportement P/Invoke. La structure ReadOnlySpan<T> Récemment ajoutée peut être utilisée pour obtenir une sous-chaîne sans copie:

string s = "Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);

La "sous-chaîne" ReadOnlySpan<char> Stocke la longueur de manière indépendante, et ne garantit pas qu'il y ait un '\ 0' après la fin de la valeur. Il peut être utilisé de nombreuses manières "comme une chaîne", mais ce n'est pas "une chaîne" car il n'a pas de caractéristiques BStr ou CStr (et encore moins les deux). Si vous n'avez jamais (directement) P/Invoke, il n'y a pas beaucoup de différence (à moins que l'API que vous souhaitez appeler n'ait pas de surcharge ReadOnlySpan<char>).

ReadOnlySpan<char> Ne peut pas être utilisé comme champ d'un type de référence, donc il y a aussi ReadOnlyMemory<char> (s.AsMemory(0, 5)), qui est un moyen indirect d'avoir un ReadOnlySpan<char> , donc les mêmes différences-de -string existent.

Certaines des réponses/commentaires sur les réponses précédentes évoquaient le gaspillage du fait que le ramasse-miettes conserve une chaîne d'un million de caractères pendant que vous continuez à parler de 5 caractères. C'est précisément le comportement que vous pouvez obtenir avec l'approche ReadOnlySpan<char>. Si vous ne faites que des calculs courts, l'approche ReadOnlySpan est probablement meilleure. Si vous avez besoin de la conserver pendant un moment et que vous n'allez conserver qu'un petit pourcentage de la chaîne d'origine, créer une sous-chaîne appropriée (pour supprimer les données en excès) est probablement préférable. Il y a un point de transition quelque part au milieu, mais cela dépend de votre utilisation spécifique.

2
bartonjs