Есть множество базовых методов, которые мы регулярно используем, даже не задумываясь. Ну а что, если задуматься и посмотреть, каким образом реализованы некоторые простые, на первый взгляд, методы?
Думаю, это поможет нам стать на шаг ближе к Java)
Если нужен короткий ответ, то ![Деревянные фишки для скрабла с латинскими буквами от A до Z, рассыпанные на белом фоне]()
Дело в том, что в каждом объекте
Также мы видим некоторые переменные:
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.
Синтаксис
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 машинах они меняются местами.
В последней строке данного метода:
- Берется элемент из массива value и побитово сдвигается на
HI_BYTE_SHIFT, то есть 0, при этом увеличиваяindex +1.В примере со строкой
"абвг"байт с индексом 6, то есть 51, так бы и остался, но при этом индекс увеличивается до 7. - После этого берется следующий элемент массива и так же побитово сдвигается, но на
LO_BYTE_SHIFT, то есть на 8 битов.И если у нас это был байт 4, который имеет двоичное представление 00000000 00000100, то после сдвига на 8 битов у нас будет 00000100 00000000. Если целым числом, то 1024.
- Далее для этих двух значений следует операция
| (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.