В этой статье разберем, как PHP работает со строками и почему даже простой вызов strlen()
может привести к неожиданным результатам. На примере крайне простой задачи "что выведет echo strlen('привет!');
" посмотрим, что и как влияет на подсчет длины строки, заглянем внутрь реализации strlen()
и развенчаем миф о строках как массивах.
Задача в телеге
В одной группе телеграмма с задачами по PHP недавно встретил простой вопрос: что выведет код «echo strlen(‘привет!’);»?
Был вопрос и 4 варианта ответа.
Вроде бы все просто. Но правильно ответили лишь 16%. Наиболее популярным ответом оказался «7», что неверно. Но именно его дали 71% ответивших.
Вопрос на собеседовании
Иногда на собеседованиях PHP-разработчиков я задаю простой вопрос – «как узнать длину строки в PHP»? Вроде как простой вопрос, многие книжки начинаются с этой информации :) Но давайте расскажу про полученные ответы.
Чаще всего отвечали – через strlen. Дальнейший вопрос о том, что получим, отправив в функцию имя кандидата, был почти всегда неверен. Справедливости ради стоит сказать, что, поняв ошибку, многие вспоминали про кодировку. Но даже вспомнив про нее, мало кто смог объяснить почему так.
Про mb_strlen вспоминал примерно каждый пятый...
А полного понимания работы strlen я не услышал ни от кого.
Давайте разберемся
Итак, давайте разберемся. Что выведет «echo strlen(‘привет!’);» ?
В большинстве случаев – 13. Да-да, именно 13.
Если тут вы вспомнили про кодировку (обычно utf-8) и умножили на 2 – это не совсем верно, 7*2 не равно 13.
Данная функция возвращает не длину строки в понимании текста (символов), а количество байт, занимаемых строкой. В современном привычном нам web чаще всего используется кодировка utf-8, в которой каждый русский символ занимает 2 байта. Восклицательный знак занимает 1 байт. Итого 2*6+1 = 13.
А если хочется 7?
Но давайте пойдем дальше. Что, если мы такие упорные и хотим, чтобы функция все-таки вывела 7. Как это сделать? Есть мысли?
Подумайте… придумайте, как? :-)
Придумали? Если да, отлично, нет – читайте дальше.
Решение простое: сменить кодировку строки. Например, перевести её в Windows-1251.
Передайте в функцию «привет!» в cp-1251 – получите 7.
$text = "привет!";
$textWin1251 = mb_convert_encoding($text, "Windows-1251", "UTF-8");
echo strlen($textWin1251); // 7
Еще одна задачка — как работает функция
Теперь же давайте заглянем чуть глубже. Как работает эта функция и чем отличается от то же mb_strlen.
Простая задача. Попробуйте ответить самостоятельно, не подглядывая вниз.
У нас есть 3 строки.
1 строка состоит из 100 символов.
2 строка состоит из 10000 символов.
3 строка состоит из 1000000 символов.
Для простоты будем считать, что в каждой из строк только символы английского алфавита, каждый из которых занимает 1 байт. И мы последовательно вызываем strlen к каждой из строк.
Вопрос: длину какой строки мы получим быстрее, а какой дольше? И на сколько быстрее, насколько дольше?
Обычно рассуждают так: это подсчет длины, значит нужно пройтись по всем байтам и просто их посчитать. 2 строка длиннее первой в 100 раз, а третья в 100 раз длиннее второй. Значит быстрее всех первая, в 100 раз дольше будет считаться вторая, и еще в 100 раз третья. Логично? Но не верно! Давайте разберем, как это работает.
Как хранятся строки
Ответ в способе хранении переменных под капотом PHP. Каждая переменная PHP хранится в специальной структуре, называемой zval.
Если это число, то оно лежит прямо в zval . Если объект, то zval содержит ссылку на другую структуру, содержащую объект (zend_object). А если строка – то будет использоваться ссылка на специальную структуру (zend_string), которая хранит строки.
Любая строка в памяти компьютера – просто последовательность байт. На начало строки есть указатель (адрес в оперативной памяти, по которому находится первый байт строки). А дальше возникает проблема – как узнать, где конец строки?
Существует 2 основных подхода:
Нуль-терминированные строки (C-style). В одном строка читается до тех пор, пока не встретится специальный символ, означающий окончание строки (\0). Подход имеет ряд недостатков. Например, если в самой строке окажутся символы, по которым определяется конец строки – возникнет проблема.
Строки с явным хранением длины (length-prefixed). Рядом со строкой в памяти хранится количество байт, которые нужно прочитать, чтобы получить строку. В нашем случае строка хранится данным образом.
И что получаем? Strlen ничего не считает! Функция просто считывает значение в структуре, указывающее не количество символов, которые нужно прочитать для получения строки. И возвращает это количество.
В итоге strlen, примененная к строке из 1000, 100000 или 10000000 символов, выполнится примерно за одно и то же время.
Как проверить?
Приведу вариант кода, целью которого является оценка разницы скорости подсчета строк. Если захотите проверить – запустите несколько раз и сравните. Данные будут меняться, у процессора тоже есть другие дела.. И у PHP есть определенные оптимизации. Но в целом будет понятно.
Другой вариант – брать очень большие строки.
<?php
function generateString($length) {
return str_repeat('a', $length);
}
// Массив с длинами строк
$lengths = array(1, 10000, 10000000);
foreach ($lengths as $length) {
// Генерация строки
$string = generateString($length);
// Засекаем время перед выполнением strlen
$startTime = microtime(true);
// Выполняем strlen миллион раз, чтобы увидеть разницу в скорости
for ($i = 0; $i<=1000000; $i++) {
$strLength = strlen($string);
}
// Засекаем время после выполнения strlen
$endTime = microtime(true);
// Вычисляем и выводим затраченное время
$executionTime = $endTime - $startTime;
echo "Длина строки: $length символов. Время выполнения strlen: $executionTime секунд.\n";
}
?>
Пример результата выполнения скрипта:
Длина строки: 1 символов. Время выполнения strlen: 0.026342153549194 секунд.
Длина строки: 10000 символов. Время выполнения strlen: 0.024653196334839 секунд.
Длина строки: 10000000 символов. Время выполнения strlen: 0.022591114044189 секунд.
Считаем символы — mb_strlen
В отличие от strlen, которая просто берёт длину из структуры zend_string, mb_strlen делает следующее:
1. Получает кодировку. Если кодировка не указана явно, использует internal_encoding из php.ini.
2. Посимвольно анализирует строку, проверяя битовые маски UTF-8 (напишу про них в конце статьи). Если кратко – определяет границы каждого символа в строке.
3. Считает и возвращает количество символов, а не байт.
Из-за этого mb_strlen медленнее, чем strlen, но без неё не обойтись при работе с Unicode.
echo mb_strlen('привет!', 'UTF-8'); // 7
Для проверки можно взять код из примера выше. Если заменить функцию strlen на mb_strlen, время подсчета сильно изменится и будет четко видна зависимость времени подсчета от длины строки. (Осторожно, подобное изменение может «повесить» скрипт с миллионом итераций - это достаточно много для mb_strlen!)
А что про массивы?
Неоднократно слышал мнение, что строка в PHP является массивом. Давайте разберемся с этим мифом.
Массив в PHP – это 2 разные структуры (для ассоциативного массива и массива с ключами от 0 до N структуры отличаются) . Важно то, что это специальные структуры в памяти.
Строка – просто последовательность байт (zend_string) с ее длиной. Массив может содержать значения разных типов, поддерживать сложные структуры (многомерные массивы). Строка же - просто последовательность байт, которая хранит текстовые данные или бинарные данные и имеет кодировку.
Многие функции, применяемые к массивам, нельзя применить к строкам. И наоборот. Строка ведёт себя подобно массиву символов в некоторых аспектах, но технически это отдельный тип данных. Да, PHP разрешает доступ к символам строки через квадратные скобки [], но это просто синтаксический сахар.
Более того, доступ через [] будет корректно работать для однобайтовых символов:
$a = 'abc';
echo $a[1]; // b
Но с многобайтовыми символами [] возвращает отдельный байт, а не целый символ:
$a = 'абв';
echo $a[1]; // второй байт UTF-8 строки, который сам по себе не является валидным символом
Немного про utf-8
Частый вопрос: если в utf-8 разные символы занимают разное количество байт, как же определить, где заканчивается один символ и начинается другой?
Тут все просто. Каждый символ начинается с определенной последовательности бит, отличающейся для 1,2,3 и 4ого байтов.
Если байт начинается с 0, это однобайтовый символ (ASCII).
Если с 110, это начало 2-байтового символа, и следующий байт должен начинаться с 10.
Если с 1110, начало 3-байтового, и дальше идут два байта 10xxxxxx.
Если с 11110, начало 4-байтового, за которым следуют три байта 10xxxxxx.
Кто хочет глубже разобраться - https://datatracker.ietf.org/doc/html/rfc3629
Вывод
strlen()
возвращает байты, а mb_strlen()
считает символы. Для текста в UTF-8 всегда используйте mb_strlen()
.
Строки в PHP — не массивы. Доступ через []
опасен для Unicode.
strlen()
работает за O(1), потому что длина хранится в структуре строки.
UPDATE! Заглянем ещё глубже: grapheme_strlen()
Немного полезной для понимания устройства строк информации.
Кодпоинт — числовое значение символа в стандарте Unicode (например, U+0065 для буквы e).
Графема — минимальная единица письменности, воспринимаемая как цельный символ (например, é или 👨👩👧👦).
$word = "sincérité"; // Акценты над 'e' и 'i' — комбинирующиеся символы
echo strlen($word); // 13 (байты в UTF-8)
echo mb_strlen($word); // 11 (кодпоинты Unicode)
echo grapheme_strlen($word); // 9 (графемы)
strlen() считает байты. В UTF-8 комбинирующиеся символы (например, акут ´) кодируются отдельными байтами, поэтому их длина суммируется.
mb_strlen() считает кодпоинты Unicode, но не всегда соответствует визуальным символам.
Например, "é" может быть:
Одним кодпоинтом U+00E9 (латинская e с акутом).
Или парой кодпоинтов: U+0065 (e) + U+0301 (комбинирующий акут).
Во втором случае mb_strlen() вернёт 2, хотя визуально это один символ.
grapheme_strlen() считает графемные кластеры — группы кодпоинтов, которые образуют символ, который мы видим на экране.
$emoji = "👨👩👧👦"; // 4 человека
echo mb_strlen($emoji, 'UTF-8'); // 7
echo grapheme_strlen($emoji); // 1 (одна графема)
$accented = "café";
echo mb_strlen($accented); // 4 или 5 (зависит от нормализации Unicode)
echo grapheme_strlen($accented); // Всегда 4 (буква `é` — одна графема)