Есть множество базовых методов, которые мы регулярно используем, даже не задумываясь. Ну а что, если задуматься и посмотреть, каким образом реализованы некоторые простые, на первый взгляд, методы? Думаю, это поможет нам стать на шаг ближе к Java) Если нужен короткий ответ, то charAt() это метод класса String, который возвращает символ по его номеру в строке. Номера начинаются с нуля, а если попросить символ за границами строки, метод бросит StringIndexOutOfBoundsException. Все интересное начинается дальше: внутри charAt() устроен совсем не так прямолинейно, как кажется. Представим ситуацию, в которой нам нужно вытащить определенный символ в какой-то строке. Как мы это можем сделать в Java? Например, с помощью вызова метода charAt() класса String. О методе charAt() мы и поговорим в сегодняшней статье.

Кратко

  • charAt(int index) возвращает один char по индексу. Допустимые индексы это от 0 до length()-1, все остальное дает StringIndexOutOfBoundsException.
  • С Java 9 строка внутри хранится не в char[], а в byte[] плюс флаг кодировки. Поэтому у метода два пути.
  • Если в строке только символы Latin-1, работает StringLatin1: один байт на символ, маска & 0xff и приведение к char.
  • Если есть что-то кроме них, работает StringUTF16: два байта на символ, которые собираются в один char сдвигами и операцией OR.
  • Индекс в UTF-16-ветке умножается на два сдвигом index <<= 1, чтобы попасть на первый байт нужного символа.
  • Результат приводится к char, а char в Java это код символа в Unicode, а не в ASCII.
Деревянные фишки для скрабла с латинскими буквами от A до Z, рассыпанные на белом фоне

Синтаксис

char charAt(int index) возвращает значение char по указанному индексу. Индекс колеблется от 0 до length()-1. То есть, первое char значение последовательности находится в index 0, следующее в index 1 и т.д., как и в случае с индексацией массива.

Пример


public static void main(String[] args) {
   System.out.print("JavaRush".charAt(0));
   System.out.print("JavaRush".charAt(1));
   System.out.print("JavaRush".charAt(2));
   System.out.print("JavaRush".charAt(3));
}
В первой строке берется первый символ, во второй второй, и так далее. Так как здесь используется не println, а print, без перехода на новую строку, мы получим вывод в консоль:

Java
Если char под заданным индексом представлен в виде юникода, результатом работы метода charAt() будет символ, который представляет данный юникод:

System.out.println("J\u0061vaRush".charAt(1));
Вывод в консоль:

a

Что “под капотом”

Как же оно работает, спросите вы?Открытый капот автомобиля с хромированным воздушным фильтром на двигателе: шутка про то, что происходит под капотомДело в том, что в каждом объекте String есть массив byte с байтами элементов данной строки:

private final byte[] value;
Обратите внимание: массив именно byte, а не char. До Java 9 внутри String лежал char[], то есть по два байта на любой символ, даже на латинскую букву. В Java 9 появились компактные строки (JEP 254): строка стала храниться в массиве байт, а рядом с ним лежит флаг кодировки, Latin-1 или UTF-16. Латинские строки от этого стали занимать вдвое меньше памяти, но и charAt() пришлось разделить на два случая, которые мы сейчас и разберем. А вот и сам метод charAt. Здесь и дальше листинги приведены по актуальному исходнику OpenJDK:

public char charAt(int index) {
   if (isLatin1()) {
       return StringLatin1.charAt(value, index);
   } else {
       return StringUTF16.charAt(value, index);
   }
}
isLatin1 это флаг, указывающий на то, есть ли в нашей строке только латинские символы или нет. От этого зависит, какой метод будет вызываться далее.
Условие Кодировка внутри Байт на символ Кто работает Что делает
isLatin1() вернул true Latin-1 1 StringLatin1 берет один байт и снимает знак маской & 0xff
isLatin1() вернул false UTF-16 2 StringUTF16 берет два байта и собирает из них char сдвигами и OR

isLatin1 = true

Если в строке есть только латинские символы, вызывается статический метод charAt класса StringLatin1:

static char charAt(byte[] value, int index) {
   String.checkIndex(index, value.length);
   return (char)(value[index] & 0xff);
}
Первым делом индекс отправляется на проверку в String.checkIndex. Там сверяют, что он не отрицательный и не выходит за пределы внутреннего массива байт, а если это не так, кидают StringIndexOutOfBoundsException. Если же проверки пройдены, далее берется нужный нам элемент. Другие методы для работы со строками разобраны в отдельной статье. В конце мы видим:
  • & это побитовое И. Перед ним byte автоматически расширяется до int, и расширение это знаковое: байт со старшим битом, например (byte) 0xE9, превратится не в 233, а в -23.
  • 0xff поэтому и нужен: маска обнуляет все лишние биты и оставляет ровно те восемь, что были в байте. Получается честное число от 0 до 255.
  • (char) превращает это число в символ. Для однобайтовой строки Java использует Latin-1, а не ASCII: в ASCII всего 128 символов, а в байт помещается 256.

isLatin1 = false

Если же у нас присутствовали не только латинские символы, будет использоваться класс StringUTF16 и вызываться его статический метод:

static char charAt(byte[] value, int index) {
   checkIndex(index, value);
   return getChar(value, index);
}
Который в свою очередь вызывает:

static void checkIndex(int off, byte[] val) {
   String.checkIndex(off, length(val));
}
А он делегирует статическому методу String:

static void checkIndex(int index, int length) {
   Preconditions.checkIndex(index, length, Preconditions.SIOOBE_FORMATTER);
}
Здесь, собственно, и происходит проверка на допустимость индекса. Саму работу выполняет служебный класс Preconditions: он сверяет, что индекс не отрицательный и меньше длины, а иначе бросает StringIndexOutOfBoundsException. В версиях JDK постарше эта же проверка была написана прямо здесь, обычным if с throw. Но в классе StringUTF16 в методе charAt более интересным будет вызов второго метода:

static char getChar(byte[] val, int index) {
   assert val != null && index >= 0 && index < length(val) : "Trusted caller violated input constraints";
   index <<= 1;
   return (char)(((val[index++] & 0xff) << HI_BYTE_SHIFT) |
                 ((val[index]   & 0xff) << LO_BYTE_SHIFT));
}
Приступим же к разбору, что тут собственно происходит. Первым делом в начале метода идет еще одна проверка допустимости индекса. Чтобы понять происходящее далее, нужно уяснить: когда не латинский символ попадает в массив value, его представляют два байта (две ячейки массива). Если у нас есть строка из двух кириллических символов, “ав”, то:
  • для ‘а’ это пара байтов 48 и 4;
  • для ‘в’ это 50 и 4.
То есть, если мы создадим строку “ав”, у нее будет массив value вида {48, 4, 50, 4}. Собственно, в этом методе и идет работа с двумя ячейками массива value. Поэтому дальше идет сдвиг index <<= 1;, чтобы попасть непосредственно на индекс первого байта искомого символа в массиве value. А теперь допустим, что у нас есть строка "абвг". Тогда массив value будет выглядеть так: {48, 4, 49, 4, 50, 4, 51, 4}. Мы запрашиваем символ с индексом 3, то есть четвертую букву. Двоичное представление этого индекса такое: 00000000 00000011. При сдвиге на 1, мы получим 00000000 00000110, то есть index = 6. Чтобы освежить знания по побитовым операциям, можешь почитать вот эту статью.Старая фотография: молодой человек в очках сидит за настольным компьютером девяностых в комнате с деревянными панелямиТакже мы видим некоторые переменные: HI_BYTE_SHIFT в данном случае равно 0. LO_BYTE_SHIFT в данном случае равно 8. Слова «в данном случае» тут не для красоты: значения этих двух констант зависят от порядка байтов у процессора. На привычных нам x86 и ARM порядок little-endian, и тогда HI_BYTE_SHIFT равен 0, а LO_BYTE_SHIFT равен 8. На big-endian машинах они меняются местами. В последней строке данного метода:
  1. Берется элемент из массива value и побитово сдвигается на HI_BYTE_SHIFT, то есть 0, при этом увеличивая index +1.

    В примере со строкой "абвг" байт с индексом 6, то есть 51, так бы и остался, но при этом индекс увеличивается до 7.

  2. После этого берется следующий элемент массива и так же побитово сдвигается, но на LO_BYTE_SHIFT, то есть на 8 битов.

    И если у нас это был байт 4, который имеет двоичное представление 00000000 00000100, то после сдвига на 8 битов у нас будет 00000100 00000000. Если целым числом, то 1024.

  3. Далее для этих двух значений следует операция | (OR).

    И если у нас были байты 51 и 1024, которые в двоичном представлении выглядели как 00000000 00110011 и 00000100 00000000, то после операции OR мы получим 00000100 00110011, что значит число 1075 в десятичной системе.

    Ну и в конце концов число 1075 переводится в тип char. Никакого ASCII тут нет и быть не может: в ASCII всего 128 символов, а char в Java хранит код символа в Unicode. Код 1075, он же U+0433, как раз и соответствует букве ‘г’.

Собственно, таким образом мы и получим ‘г’ как результат работы метода charAt() в Java-программировании.

Подвох: char это не всегда символ

Из всего разобранного следует вывод, о котором лучше узнать заранее. charAt() возвращает не символ, а одну 16-битную единицу UTF-16. Для латиницы и кириллицы это одно и то же, а вот для эмодзи, редких иероглифов и многих других знаков уже нет: их коды не помещаются в 16 бит, поэтому такой символ хранится суррогатной парой, то есть двумя char подряд.

public static void main(String[] args) {
   String smile = "\uD83D\uDE00"; // один смайлик, U+1F600
   System.out.println(smile.length());        // 2
   System.out.println((int) smile.charAt(0)); // 55357
   System.out.println((int) smile.charAt(1)); // 56832
   System.out.println(smile.codePointAt(0));  // 128512
}
Строка из одного смайлика имеет длину 2, а каждый charAt() отдает половинку пары, которая сама по себе не значит ничего: 55357 и 56832 это не символы, а служебные коды из диапазона суррогатов. Настоящий код символа, 128512, он же U+1F600, получается только из обеих половинок вместе. Для этого и существует codePointAt(): он смотрит, не начинается ли по указанному индексу суррогатная пара, и если да, собирает из двух char полный код. Так что перебирать строку посимвольно через charAt() безопасно ровно до тех пор, пока в ней нет ничего за пределами базовой плоскости Unicode. Подробнее эта история разобрана в статье про Unicode в Java.

Вопросы и ответы

Что делает метод charAt() в Java?

Возвращает один символ строки по его номеру. Сигнатура у него такая: char charAt(int index). Например, "JavaRush".charAt(0) вернет букву J. Метод объявлен в классе String, а точнее в интерфейсе CharSequence, который String реализует.

С какого числа начинается нумерация символов в строке?

С нуля, как и в массивах. Первый символ лежит по индексу 0, второй по индексу 1 и так далее. Последний допустимый индекс это length() минус один: у строки из восьми символов это 7.

Что будет, если передать в charAt() неверный индекс?

Метод бросит StringIndexOutOfBoundsException. Это касается и отрицательного индекса, и индекса, равного длине строки или большего. Проверка стоит внутри самого метода, до того как он полезет в массив, так что до чтения чужой памяти дело не доходит.

Почему внутри String массив byte, а не char?

Так сделали в Java 9 ради экономии памяти, это изменение называется компактными строками. Большинство строк в реальных программах состоят только из символов Latin-1, а на такой символ хватает одного байта вместо двух. Рядом с массивом хранится флаг кодировки, по которому charAt() и понимает, как читать данные.

Зачем в коде charAt() маска 0xff?

Затем, что byte в Java знаковый. Перед побитовой операцией он расширяется до int со знаком, и байт со старшим единичным битом превратился бы в отрицательное число. Маска 0xff обнуляет лишние биты и оставляет ровно восемь исходных, то есть значение от 0 до 255.

Почему для кириллицы charAt() работает иначе, чем для латиницы?

Кириллица не помещается в Latin-1, поэтому такая строка хранится в UTF-16 и каждый символ занимает два байта. Из-за этого индекс сначала умножается на два, потом из двух соседних байтов сдвигами и операцией OR собирается один char. Для латинской строки хватает одного байта, и вся работа сводится к маске и приведению типа.

Читайте также