Если строки являются неизменяемыми в .NET, то почему Substring занимает O (n) времени?

451

Учитывая, что строки являются неизменяемыми в .NET, мне интересно, почему они были разработаны таким образом, что вместо string.Substring()O? substring.Length) Требуется время O(1)?

т.е. каковы были компромиссы, если таковые имеются?

user541686
источник
3
@Mehrdad: мне нравится этот вопрос. Не могли бы вы сказать мне, как мы можем определить O () данной функции в .Net? Это понятно или мы должны это посчитать? Спасибо
odiseh
1
@odiseh: Иногда (как в этом случае) ясно, что строка копируется. Если это не так, то вы можете либо просмотреть документацию, выполнить тесты или попробовать заглянуть в исходный код .NET Framework, чтобы выяснить, что это такое.
user541686

Ответы:

423

ОБНОВЛЕНИЕ: мне очень понравился этот вопрос, я просто написал в блоге. См Строки, неизменность и постоянство


Короткий ответ: O (n) равно O (1), если n не становится большим. Большинство людей извлекают крошечные подстроки из крошечных строк, поэтому то, как сложность асимптотически возрастает, совершенно не имеет значения .

Длинный ответ:

Неизменяемая структура данных, построенная так, что операции с экземпляром позволяют повторно использовать память оригинала с небольшим объемом (обычно O (1) или O (lg n)) копирования или нового выделения, называется «постоянным» неизменяемая структура данных. Строки в .NET являются неизменяемыми; Ваш вопрос по сути "почему они не являются постоянными"?

Потому что, когда вы смотрите на операции, которые обычно выполняются над строками в программах .NET, во всех соответствующих случаях едва ли вообще хуже просто создать совершенно новую строку. Стоимость и сложность построения сложной постоянной структуры данных не окупаются.

Люди обычно используют «подстроку» для извлечения короткой строки - скажем, десяти или двадцати символов - из несколько более длинной строки - возможно, из пары сотен символов. У вас есть строка текста в файле, разделенном запятыми, и вы хотите извлечь третье поле, которое является фамилией. Длина строки может составить пару сотен символов, а название - пару десятков. Распределение строк и копирование памяти из пятидесяти байтов удивительно быстро на современном оборудовании. То, что создание новой структуры данных, состоящей из указателя на середину существующей строки и длины, также удивительно быстро, не имеет значения; «достаточно быстро» по определению достаточно быстро.

Извлекаемые подстроки, как правило, имеют небольшой размер и короткий срок службы; сборщик мусора скоро вернет их, и они не заняли много места в куче. Поэтому использование устойчивой стратегии, которая поощряет повторное использование большей части памяти, также не является победой; все, что вы сделали, - замедлили сборщик мусора, потому что теперь он должен беспокоиться о работе с внутренними указателями.

Если бы операции с подстрокой, которые люди обычно выполняли со строками, были совершенно другими, то имело бы смысл придерживаться постоянного подхода. Если бы у людей обычно были строки из миллионов символов, и они извлекали тысячи перекрывающихся подстрок с размерами в диапазоне сотен тысяч символов, и эти подстроки долгое время жили в куче, тогда было бы разумно использовать постоянную подстроку. подходить; это было бы расточительно и глупо не делать этого. Но большинство программистов, занимающихся бизнесом, не делают ничего, даже смутно подобного, .NET не является платформой, адаптированной для нужд проекта «Геном человека»; Программисты анализа ДНК должны решать проблемы с этими характеристиками использования строк каждый день; хорошие шансы, что вы нет. Те немногие, кто создает свои собственные постоянные структуры данных, точно соответствуют сценариям их использования.

Например, моя команда пишет программы, которые на ходу анализируют код C # и VB по мере его ввода. Некоторые из этих файлов кода огромны, и поэтому мы не можем делать O (n) -текстовые манипуляции для извлечения подстрок или вставки или удаления символов. Мы создали множество постоянных неизменяемых структур данных для представления изменений в текстовом буфере, что позволяет нам быстро и эффективно повторно использовать большую часть существующих строковых данных и существующих лексических и синтаксических анализов при типичном редактировании. Это была трудная проблема, и ее решение было узко приспособлено для конкретной области редактирования кода на C # и VB. Было бы нереально ожидать, что встроенный строковый тип решит эту проблему для нас.

Эрик Липперт
источник
47
Было бы интересно противопоставить то, как Java это делает (или, по крайней мере, когда-то в прошлом) это: Substring возвращает новую строку, но указывает на тот же символ [], что и большая строка - это означает, что больший символ [] больше нельзя собирать мусор, пока подстрока не выйдет из области видимости. Я предпочитаю реализацию .net на сегодняшний день.
Майкл Стум
13
Я немного видел такой код: string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...или другие его версии. Я имею в виду прочитать весь файл, а затем обработать различные части. Код такого рода будет значительно быстрее и потребует меньше памяти, если строка будет постоянной; у вас всегда будет ровно одна копия файла в памяти вместо того, чтобы копировать каждую строку, а затем части каждой строки в процессе ее обработки. Однако, как сказал Эрик, это не типичный вариант использования.
конфигуратор
18
@configurator: Кроме того, в .NET 4 метод File.ReadLines разбивает текстовый файл на строки для вас без необходимости сначала считывать все это в память.
Эрик Липперт
8
@Michael: Java Stringреализован как постоянная структура данных (это не указано в стандартах, но все известные мне реализации делают это).
Иоахим Зауэр
33
Краткий ответ: Копия данных сделана, чтобы позволить сборку мусора исходной строки .
Qtax
121

Именно потому, что строки являются неизменяемыми, .Substringнеобходимо сделать копию хотя бы части исходной строки. Создание копии из n байтов должно занять O (n) времени.

Как вы думаете, вы бы скопировали кучу байтов в постоянное время?


РЕДАКТИРОВАТЬ: Mehrdad предлагает вообще не копировать строку, но сохранить ссылку на ее часть.

Рассмотрим в .Net строку размером в несколько мегабайт, по которой кто-то звонит .SubString(n, n+3)(для любого n в середине строки).

Теперь ВСЮ строку нельзя собирать мусором только потому, что одна ссылка содержит до 4 символов? Это кажется нелепой тратой пространства.

Кроме того, отслеживание ссылок на подстроки (которые могут даже находиться внутри подстрок) и попытка копирования в оптимальные моменты времени, чтобы избежать победы над GC (как описано выше), делают эту концепцию кошмаром. Гораздо проще и надежнее копировать .SubStringи поддерживать прямую неизменную модель.


РЕДАКТИРОВАТЬ: Вот хорошее небольшое чтение об опасности сохранения ссылок на подстроки в более крупных строках.

abelenky
источник
5
+1: именно мои мысли. Внутренне это, вероятно, использует, memcpyкоторый все еще O (n).
Леппи
7
@abelenky: Я думаю, может быть, не копируя это вообще? Это уже там, почему вы должны скопировать его?
user541686
2
@ Mehrdad: ЕСЛИ ты после выступления. Просто будьте небезопасны в этом случае. Тогда вы можете получить char*подстроку.
Леппи
9
@Mehrdad - вы, возможно, ожидаете слишком многого, он называется StringBuilder , и он хорош для построения строк. Это не называется StringMultiPurposeManipulator
MattDavey
3
@SamuelNeff, @Mehrdad: Строки в .NET не NULL заканчиваются. Как объясняется в посте Липперта , первые 4 байта содержат длину строки. Вот почему, как указывает Скит, они могут содержать \0символы.
Elideb
33

Java (в отличие от .NET) предоставляет два способа работы Substring(): вы можете решить, хотите ли вы сохранить только ссылку или скопировать целую подстроку в новое место в памяти.

Простой .substring(...)разделяет используемый внутри charмассив с исходным объектом String, который затем new String(...)можно при необходимости скопировать в новый массив (чтобы не мешать сборке мусора исходного).

Я думаю, что такая гибкость - лучший вариант для разработчика.

SLL
источник
50
Вы называете это «гибкостью», я называю это «способом случайно вставить трудно диагностируемую ошибку (или проблему с производительностью) в программное обеспечение, потому что я не осознавал, что должен остановиться и подумать обо всех местах, где этот код может быть вызывается из (включая те, которые будут изобретены только в следующей версии), чтобы получить 4 символа из середины строки "
Nir
3
downvote убрано ... После более тщательного просмотра кода он выглядит как подстрока в java, ссылающаяся на общий массив, по крайней мере, в версии openjdk. И если вы хотите обеспечить новую строку, есть способ сделать это.
Дон Роби,
11
@Nir: я называю это "предвзятым отношением к статусу-кво". Вам способ Java делать это кажется рискованным, а путь .Net - единственным разумным выбором. Для программистов на Java дело обстоит иначе.
Майкл Боргвардт
7
Я сильно предпочитаю .NET, но это звучит так, как будто Java правильно понял. Полезно, чтобы разработчику было разрешено иметь доступ к действительно методу O (1) Substring (без использования собственного строкового типа, который препятствовал бы взаимодействию с любой другой библиотекой и не был бы столь же эффективным, как встроенное решение). ). Решение Java, вероятно, неэффективно (требуются как минимум два объекта кучи, один для исходной строки и другой для подстроки); языки, которые поддерживают срезы, эффективно заменяют второй объект парой указателей в стеке.
Qwertie
10
Начиная с JDK 7u6 это уже не так - теперь Java всегда копирует содержимое String для каждого .substring(...).
Xaerxess
12

Ява использовалась для ссылки на более крупные строки, но:

Java также изменила свое поведение на копирование , чтобы избежать утечки памяти.

Я чувствую, что это можно улучшить, хотя: почему бы просто не сделать условное копирование?

Если подстрока по крайней мере вдвое меньше родительского, можно ссылаться на родительский. В противном случае можно просто сделать копию. Это позволяет избежать утечки большого количества памяти, но при этом обеспечивает значительную выгоду.

user541686
источник
Всегда копирование позволяет удалить внутренний массив. Уменьшает вдвое количество выделений кучи, экономя память в общем случае коротких строк. Это также означает, что вам не нужно перепрыгивать через дополнительные косвенные ссылки для каждого доступа персонажа.
CodesInChaos
2
Я думаю, что важно принять во внимание то, что Java фактически изменилась с использования одной и той же базы char[](с разными указателями на начало и конец) на создание новой String. Это ясно показывает, что анализ затрат и выгод должен показывать предпочтение созданию нового String.
Филогенез
2

Ни один из приведенных здесь ответов не относится к «проблеме скобок», то есть строки в .NET представлены в виде комбинации BStr (длина, хранящаяся в памяти «до» указателя) и CStr (строка заканчивается на '\ 0').

Строка "Hello there", таким образом, представляется как

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

(если он назначен char*в fixed-состоянии, указатель будет указывать на 0x48.)

Эта структура обеспечивает быстрый поиск длины строки (полезно во многих контекстах) и позволяет передавать указатель в API P / Invoke для Win32 (или других), которые ожидают строку с нулевым символом в конце.

Когда вы выполняете Substring(0, 5)правило «о, но я обещал, что после последнего символа будет нулевой символ», вам нужно сделать копию. Даже если вы получили подстроку в конце, тогда не было бы места для длины без искажения других переменных.


Однако иногда вы действительно хотите поговорить о «середине строки», и вам не обязательно заботиться о поведении P / Invoke. Недавно добавленная ReadOnlySpan<T>структура может быть использована для получения подстроки без копирования:

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

ReadOnlySpan<char>«Подстрока» сохраняет длину независимо друг от друга, и это не гарантия того, что есть «\ 0» после конца значения. Он может быть использован во многих отношениях «как строка», но это не «строка», поскольку он не имеет характеристик BStr или CStr (тем более, что они оба). Если вы никогда (напрямую) не вызываете P / Invoke, то нет особой разницы (если только API, который вы хотите вызвать, не ReadOnlySpan<char>перегружен).

ReadOnlySpan<char>не может использоваться в качестве поля ссылочного типа, поэтому есть также ReadOnlyMemory<char>( s.AsMemory(0, 5)), который является косвенным способом иметь ReadOnlySpan<char>, поэтому такие же отличия от stringсуществующих.

В некоторых ответах / комментариях к предыдущим ответам говорилось о расточительности, когда сборщик мусора должен хранить строку из миллиона символов, пока вы продолжаете говорить о 5 символах. Именно такое поведение вы можете получить при ReadOnlySpan<char>подходе. Если вы просто делаете короткие вычисления, подход ReadOnlySpan, вероятно, лучше. Если вам нужно сохранить его на некоторое время, и вы собираетесь сохранить только небольшой процент от исходной строки, возможно, лучше сделать правильную подстроку (чтобы обрезать лишние данные). Где-то посередине есть точка перехода, но это зависит от вашего конкретного использования.

bartonjs
источник