Se le stringhe sono immutabili in .NET, perché Substring impiega O (n) tempo?


451

Dato che le stringhe sono immutabili in .NET, mi chiedo perché siano state progettate in modo tale da string.Substring()richiedere tempo O ( substring.Length) invece di O(1)?

cioè quali sono stati i compromessi, se ce ne sono?


3
@Mehrdad: questa domanda mi piace. Potresti dirmi come possiamo determinare O () di una data funzione in .Net? È chiaro o dovremmo calcolarlo? Grazie
odiseh

1
@odiseh: A volte (come in questo caso) è chiaro che la stringa viene copiata. In caso contrario, puoi consultare la documentazione, eseguire benchmark o provare a cercare nel codice sorgente di .NET Framework per capire di cosa si tratta.
user541686,

Risposte:


423

AGGIORNAMENTO: questa domanda mi è piaciuta così tanto, l'ho solo bloggata. Vedi Stringhe, immutabilità e persistenza


La risposta breve è: O (n) è O (1) se n non diventa grande. La maggior parte delle persone estrae minuscole sottostringhe da minuscole stringhe, quindi il modo in cui la complessità cresce asintoticamente è completamente irrilevante .

La lunga risposta è:

Una struttura di dati immutabile costruita in modo tale che le operazioni su un'istanza consentano il riutilizzo della memoria dell'originale con solo una piccola quantità (in genere O (1) o O (lg n)) di copia o nuova allocazione è chiamata "persistente" struttura dei dati immutabile. Le stringhe in .NET sono immutabili; la tua domanda è essenzialmente "perché non sono persistenti"?

Perché quando si osservano operazioni che vengono in genere eseguite su stringhe nei programmi .NET, in tutti i modi rilevanti non è affatto peggio semplicemente creare una stringa completamente nuova. La spesa e la difficoltà di costruzione di una struttura di dati persistente complessa non si ripagano da soli.

Le persone in genere usano la "sottostringa" per estrarre una stringa corta - diciamo, dieci o venti caratteri - da una stringa un po 'più lunga - forse un paio di centinaia di caratteri. Hai una riga di testo in un file separato da virgola e vuoi estrarre il terzo campo, che è un cognome. La linea sarà lunga forse circa duecento caratteri, il nome sarà una dozzina. L'allocazione delle stringhe e la copia della memoria di cinquanta byte è sorprendentemente veloce sull'hardware moderno. Che fare una nuova struttura di dati costituita da un puntatore a mezzo di una stringa esistente più un intervallo è inoltre sorprendentemente veloce è irrilevante; "abbastanza veloce" è per definizione abbastanza veloce.

Le sottostringhe estratte sono in genere di piccole dimensioni e di breve durata; il garbage collector li recupererà presto e non hanno occupato molto spazio sul mucchio in primo luogo. Quindi anche usare una strategia persistente che incoraggia il riutilizzo della maggior parte della memoria non è una vittoria; tutto ciò che hai fatto è rallentare il tuo garbage collector perché ora deve preoccuparsi di gestire i puntatori interni.

Se le operazioni di sottostringa che le persone facevano in genere sulle stringhe erano completamente diverse, sarebbe logico seguire un approccio persistente. Se in genere le persone avessero stringhe di milioni di caratteri e stessero estraendo migliaia di sottostringhe sovrapposte con dimensioni nell'intervallo di centomila caratteri e tali sottostringhe vivessero a lungo sull'heap, sarebbe perfettamente logico procedere con una sottostringa persistente approccio; sarebbe inutile e sciocco non farlo. Ma la maggior parte dei programmatori line-of-business non fa nulla di simile a quel tipo di cose vagamente. .NET non è una piattaforma su misura per le esigenze del Progetto genoma umano; I programmatori di analisi del DNA devono risolvere ogni giorno problemi con quelle caratteristiche di utilizzo delle stringhe; le probabilità sono buone che tu non lo faccia. I pochi che costruiscono le proprie strutture di dati persistenti che corrispondono strettamente ai loro scenari di utilizzo.

Ad esempio, il mio team scrive programmi che eseguono analisi al volo del codice C # e VB durante la digitazione. Alcuni di questi file di codice sono enormi e quindi non possiamo eseguire la manipolazione di stringhe O (n) per estrarre sottostringhe o inserire o eliminare caratteri. Abbiamo costruito una serie di strutture di dati immutabili persistenti per rappresentare modifiche a un buffer di testo che ci permette di rapido ed efficiente riutilizzare la maggior parte dei dati di stringa esistenti e le analisi lessicali e sintattici esistenti su di un montaggio tipico. Questo è stato un problema difficile da risolvere e la sua soluzione è stata strettamente adattata al dominio specifico della modifica del codice C # e VB. Non sarebbe realistico aspettarsi che il tipo di stringa incorporato risolva questo problema per noi.


47
Sarebbe interessante mettere a confronto il modo in cui Java (o almeno lo ha fatto in passato): Sottostringa restituisce una nuova stringa, ma punta allo stesso carattere [] della stringa più grande - ciò significa che il carattere più grande [] non è più possibile raccogliere la spazzatura fino a quando la sottostringa non esce dall'ambito. Preferisco di gran lunga l'implementazione di .net.
Michael Stum

13
Ho visto un po 'di questo tipo di codice: string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...o altre versioni di esso. Intendo leggere un intero file, quindi elaborare le varie parti. Questo tipo di codice sarebbe notevolmente più veloce e richiederebbe meno memoria se una stringa fosse persistente; avresti sempre esattamente una copia del file in memoria invece di copiare ogni riga, quindi le parti di ogni riga mentre la elabori. Tuttavia, come ha detto Eric, questo non è il tipico caso d'uso.
configuratore

18
@configurator: Inoltre, in .NET 4 il metodo File.ReadLines suddivide un file di testo in righe per te, senza dover prima leggere tutto in memoria.
Eric Lippert,

8
@Michael: Java Stringè implementato come una struttura di dati persistente (non specificato negli standard, ma tutte le implementazioni che conosco lo fanno).
Joachim Sauer,

33
Risposta breve: viene creata una copia dei dati per consentire la garbage collection della stringa originale .
Qtax,

121

Proprio perché le stringhe sono immutabili, è .Substringnecessario effettuare una copia di almeno una parte della stringa originale. Fare una copia di n byte dovrebbe richiedere O (n) tempo.

Come pensi che copieresti un mucchio di byte in tempo costante ?


EDIT: Mehrdad suggerisce di non copiare affatto la stringa, ma di mantenere un riferimento a un pezzo di essa.

Considera in .Net, una stringa multi-megabyte, su cui qualcuno chiama .SubString(n, n+3)(per qualsiasi n nel mezzo della stringa).

Ora, la stringa INTERA non può essere Garbage Collected solo perché un riferimento è trattenuto da 4 caratteri? Sembra un ridicolo spreco di spazio.

Inoltre, tenere traccia dei riferimenti alle sottostringhe (che possono anche trovarsi all'interno delle sottostringhe) e cercare di copiarla in momenti ottimali per evitare di sconfiggere il GC (come descritto sopra), rende il concetto un incubo. È molto più semplice e più affidabile copiare .SubStringe mantenere il modello semplice e immutabile.


EDIT: ecco una buona lettura del pericolo di mantenere i riferimenti alle sottostringhe all'interno di stringhe più grandi.


5
+1: Esattamente i miei pensieri. Internamente usa probabilmente memcpyche è ancora O (n).
leppie,

7
@abelenky: suppongo che forse non lo copi affatto? È già lì, perché dovresti copiarlo?
user541686

2
@Mehrdad: SE stai cercando di esibirti. In questo caso, non andare sicuro. Quindi puoi ottenere una char*sottostringa.
leppie,

9
@Mehrdad - potresti aspettarti troppo lì, si chiama StringBuilder ed è buono per costruire stringhe. Non si chiama StringMultiPurposeManipulator
MattDavey il

3
@SamuelNeff, @Mehrdad: le stringhe in .NET non sono NULLterminate. Come spiegato nel post di Lippert , i primi 4 byte contengono la lunghezza della stringa. Ecco perché, come sottolinea Skeet, possono contenere \0personaggi.
Elideb,

33

Java (al contrario di .NET) offre due modi di fare Substring(), puoi considerare se vuoi mantenere solo un riferimento o copiare un'intera sottostringa in una nuova posizione di memoria.

Il semplice .substring(...)condivide l' chararray utilizzato internamente con l'oggetto String originale, che sarà quindi new String(...)possibile copiare in un nuovo array, se necessario (per evitare di ostacolare la garbage collection di quello originale).

Penso che questo tipo di flessibilità sia l'opzione migliore per uno sviluppatore.


50
Lo chiami "flessibilità", io lo chiamo "Un modo per inserire accidentalmente un bug difficile da diagnosticare (o un problema di prestazioni) nel software perché non mi rendevo conto che dovevo fermarmi e pensare a tutti i posti in cui questo codice può essere chiamato da (compresi quelli che sarebbero stati inventati solo nella prossima versione) solo per ottenere 4 caratteri dal centro di una stringa "
Nir

3
downvote ritirato ... Dopo un po 'più attenta navigazione nel codice sembra una sottostringa in riferimenti java a un array condiviso, almeno nella versione openjdk. E se vuoi assicurarti una nuova stringa, c'è un modo per farlo.
Don Roby,

11
@Nir: lo chiamo "status quo bias". A te il modo Java di farlo sembra irto di rischi e il modo .Net l'unica scelta sensata. Per i programmatori Java, è il contrario.
Michael Borgwardt,

7
Preferisco fortemente .NET, ma sembra che Java abbia funzionato bene. E 'utile che uno sviluppatore essere consentito di avere accesso a un O (1) Substring metodo veramente (senza rotolare il proprio tipo di stringa, che ostacolerebbe l'interoperabilità con ogni altra libreria, e non sarebbe efficiente come un built-in soluzione ). La soluzione di Java è probabilmente inefficiente (richiede almeno due oggetti heap, uno per la stringa originale e un altro per la sottostringa); le lingue che supportano le sezioni sostituiscono efficacemente il secondo oggetto con una coppia di puntatori nello stack.
Qwertie,

10
Dal momento che JDK 7u6 non è più vero , ora Java copia sempre il contenuto di String per ciascuno .substring(...).
Xaerxess,

12

Java faceva riferimento a stringhe più grandi, ma:

Anche Java ha cambiato il suo comportamento in copia , per evitare perdite di memoria.

Sento che può essere migliorato però: perché non limitarsi a copiare in modo condizionale?

Se la sottostringa ha almeno la metà della dimensione del genitore, si può fare riferimento al genitore. Altrimenti si può semplicemente fare una copia. Ciò evita la perdita di molta memoria pur offrendo un vantaggio significativo.


La copia sempre consente di rimuovere l'array interno. Dimezza il numero di allocazioni di heap, risparmiando memoria nel caso comune di stringhe brevi. Significa anche che non è necessario saltare attraverso un'ulteriore direzione indiretta per l'accesso di ciascun personaggio.
CodesInChaos,

2
Penso che la cosa importante da trarre da questo sia che Java sia effettivamente cambiato dall'uso della stessa base char[](con diversi puntatori all'inizio e alla fine) alla creazione di un nuovo String. Ciò dimostra chiaramente che l'analisi costi-benefici deve mostrare una preferenza per la creazione di un nuovo String.
Filogenesi dal

2

Nessuna delle risposte qui ha affrontato "il problema del bracketing", vale a dire che le stringhe in .NET sono rappresentate come una combinazione di un BStr (la lunghezza memorizzata "prima" del "puntatore) e un CStr (la stringa termina in un '\ 0').

La stringa "Hello there" è quindi rappresentata come

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

(se assegnato a a char*in fixed-statement il puntatore punta a 0x48.)

Questa struttura consente una rapida ricerca della lunghezza di una stringa (utile in molti contesti) e consente il passaggio del puntatore in API P / Invoke a Win32 (o altre) che prevedono una stringa con terminazione null.

Quando fai Substring(0, 5)la regola "oh, ma ho promesso che ci sarebbe un carattere null dopo l'ultimo carattere" dice che devi fare una copia. Anche se alla fine avessi la sottostringa, non ci sarebbe posto per inserire la lunghezza senza corrompere le altre variabili.


A volte, però, vuoi davvero parlare del "centro della stringa" e non ti preoccupi necessariamente del comportamento P / Invoke. La ReadOnlySpan<T>struttura aggiunta di recente può essere utilizzata per ottenere una sottostringa senza copia:

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

La ReadOnlySpan<char>"sottostringa" memorizza la lunghezza in modo indipendente e non garantisce che ci sia un '\ 0' dopo la fine del valore. Può essere usato in molti modi "come una stringa", ma non è "una stringa" poiché non ha né caratteristiche BStr né CStr (e tanto meno entrambe). Se non si mai (direttamente) P / Invoke, allora non c'è molta differenza (a meno che l'API che si desidera chiamare non abbia un ReadOnlySpan<char>sovraccarico).

ReadOnlySpan<char>non può essere usato come campo di un tipo di riferimento, quindi c'è anche ReadOnlyMemory<char>( s.AsMemory(0, 5)), che è un modo indiretto di avere un ReadOnlySpan<char>, quindi le stesse differenze da- stringesistono.

Alcune delle risposte / commenti sulle risposte precedenti hanno parlato del fatto che è dispendioso che il garbage collector debba tenere in giro una stringa di milioni di caratteri mentre si continua a parlare di 5 caratteri. Questo è esattamente il comportamento che puoi ottenere con l' ReadOnlySpan<char>approccio. Se stai solo eseguendo brevi calcoli, l'approccio ReadOnlySpan è probabilmente migliore. Se hai bisogno di persistere per un po 'e manterrai solo una piccola percentuale della stringa originale, probabilmente fare una sottostringa corretta (per tagliare i dati in eccesso) è probabilmente meglio. C'è un punto di transizione da qualche parte nel mezzo, ma dipende dal tuo utilizzo specifico.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.