Предисловие

Одной из частых рутин на работе является преобразование и извлечение чисел из строк текста. Самый наивный и простой подход в языке Java при преобразовании строки в число, это использовать Double.parseDouble(String num). Проблема этого метода в том, что он имеет баги в различных SDK, например в Android. Кроме того, данному методу не передаётся информация об основании системы счисления. Можно, конечно, использовать классы оболочки, передавая им в конструктор основание системы, но хотелось бы извлекать данную информацию из самой строки автоматически.

Исходная задача

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

Для каждой системы счисления, кроме десятичной определим соответствующий префикс:

  • 0x для 16-ричной.

  • 0c для 8-ричной.

  • 0b для двоичной.

У числа может быть задана экспонента. Определим три вида экспоненты, имеющие следующие префиксы:

  • 'H' | 'h' : десятичная экспонента для 16-ричных чисел, поскольку буква E уже занята (является цифрой 14 в данной системе).

  • 'E' | 'e' : десятичная экспонента для остальных чисел, чьё основание системы ниже 14.

  • 'P' | 'p' : двоичная экспонента для всех представленных чисел.

Пишем код

Так напишем же простой метод для преобразования строки в число из соответствующей системы счисления (указанной в строке) в десятичную. Данный метод должен корректно отделять целую часть от той, что следует после запятой (точки). Обработка знака и экспоненты будет дана ниже.

public class ProcessNumber {
	  private static final String digits = "0123456789ABCDEF";
   
    /* Преобразует строку num в десятичное число типа double
       из указанного основания base
       Может вызвать переполнение (выход за пределы диапазона целых чисел)! 
    */
    private static double parseNumber(String num, int base){
        num = num.toUpperCase(); // digits are in UPPER_CASE
        double val = 0;
        int i = 0;
        while(i < num.length()) // пока не кончилась строка
        {
            char c = num.charAt(i);
            if(c == '.') { // нашли точку '.'
                i++; // Переместить на следующий символ и выйти из цикла. 
                break;
            }
            int d = digits.indexOf(c); // Индексы совпадают с числами из [0..15]
            if(d == -1 || d >= base)
                return Double.NaN;
            val = base * val + d;
            i++;
        }
      
        int power = 1; // вычислить лишний порядок.
        while(i < num.length())
        {
            char c = num.charAt(i);
            int d = digits.indexOf(c);
            if(d == -1 || d >= base)
                return Double.NaN;
            power *= base; // увеличиваем степень порядка на единицу 
            val = base * val + d;
            i++;
        }
        return val / power;
    }
   
}

Сейчас метод parseNumber() выполняет ровно одну задачу. Он пытается преобразовать строку num в число типа double, начиная с указанного основания base. Если обнаружен недопустимый символ в строке num, то метод вернёт специальную константу класса Double не-число (NaN - Not a Number).

Самому методу нужно передавать строку без экспоненты, знака, и префикса основания системы счисления. Их предстоит вычислить заранее. Если есть знак минус ('-') то число просто умножается на минус единицу (-1). Если есть экспонента, то число дополнительно умножается на неё. Прежде чем приступить к их вычислению, допустим, что нам уже известны данные компоненты. Напишем метод, который делает выбор на основе полученной информации, и выполняет соответствующее умножение преобразованного числа из заданного основания на полученную экспоненту и минус единицу, если необходимо.

public class ProcessNumber {
  // ... parseNumber(String str, int base) { ... }
	
  /* num - Число
  	 e - экспонента
   	 et - тип экспоненты
     base - основание системы счисления
     sign - знак числа (num > 0 => positive, num < 0 => negative).
     esign - знак экспоненты.
	*/
	public static double parse(String num, String e, char et,
                             int base, int sign, int esign)
 	 {
        if(num == null || num.length() == 0 || base < 1) // null значения => NaN.
            return Double.NaN;

        double exp = 1; // Экспонента

  			// Двоичная экспонента (по основанию 2)
        if((et == 'P' || et == 'p') && e != null && e.length() > 0)
            exp = Math.pow(2.0, parseNumber(e, 10));
  
  			// Десятичная экспонента  (по основанию 10)
        else if( (et == 'E' || et == 'e' || et == 'H' || et == 'h') 
                 && e != null && e.length() > 0)
            exp = Math.pow(10.0, parseNumber(e, 10));
  
  			//e == null or e.length() == 0.
        // Указан тип экспоненты, но сама она отсутствует 
        else if(et == 'E' || et == 'e' || et == 'H' || et == 'h'
                || et == 'P' || et == 'p')
        {
            return Double.NaN;
        }
        else // et is not [PpEeHh] => ignore exponent (exp == 1) (Нет экспоненты)
            exp = 1;
    
    	if(esign < 0)
      		exp = 1 / exp;
    
    	double result = parseNumber(num, base); // Преобразовать численную часть.
       	result = (result == Double.NaN) ? result : result * exp;
    	 
       if(sign < 0)
			result = 0 - result; //make number negative (include minus sign)
    
		return result;
	}
}

Методу parse() уже передаются вычисленные компоненты числа, а именно: само число num, его экспонента e, основание экспоненты et, основание системы самого числа base и знак числа sign. В данном методе уже предусмотрена защита от противоречивых данных (например, когда экспонента равна null, но указан её тип, или когда основание системы счисления не является натуральным числом (меньше единицы)). В простом случае, если строка равна null, то данный метод вернёт не-число (NaN). Метод выполняет простую задачу, он просто вычисляет множители итогового выражения result, и выполняет умножение преобразованных строк (экспоненты и самого числа без неё) на переменную знака sign. А вызываемый метод processNumber() переводит строку компонента в число.

Теперь остаётся написать последний метод, который вычисляет знак, экспоненту и основание системы. Ниже дан его код.

public class ProcessNumber {
  ...
	// parseNumber(String num, int base) { ... }

	// parse(String num, String exp, char etype, int base, int sign) { ... }

  /* В отличие от parseNumber(String num, int base)
     автоматически вычисляет основание base, экспоненту e, и её тип, а также
     знак числа sign. В случае успешного вычисления, передаёт вычисленные элементы
     методу parse(), который делает выбор (условный переход) множителей
     и преобразование строковых компонент уже через parseNumber(num, base).
  */
  public static double parseNumber(String str){
        if(str == null || str.length() == 0) //null is NaN.
            return Double.NaN;
  
        int sign = 1; // знак числа.
    		int esign = 1; // знак экспоненты.
        int base = 10; // по умолчанию основание равно 10.
        int i = 0;
        if(str.charAt(0) == '-') { // Минус -> sign < 0.
            sign = -1;
            i = 1; // перейти к следующему знаку.
        }
        if(i > 0 && i == str.length()) //str is '-' (строка состоит только из '-')
            return Double.NaN;

        // suffix '0x' => 16 (hex)
        if(str.charAt(i) == '0' && (i + 1 != str.length()) && str.charAt(i + 1) == 'x') {
            base = 16;
            i += 2;
        }
        //suffix '0b' => 2 (binary)
        else if(str.charAt(i) == '0' && (i + 1 != str.length()) && str.charAt(i + 1) == 'b') {
            base = 2;
            i += 2;
        }
        //suffix '0c' => 8 (octal)
        else if(str.charAt(i) == '0' && (i + 1 != str.length()) && str.charAt(i + 1) == 'c'){
            base = 8;
            i += 2;
        }
        if(i == str.length())// строки вида (-0x -0b -0c 0x 0b 0c)
            return Double.NaN;

        //Вычислить экспоненту.
        int idx = str.indexOf('H');
        idx = (idx == -1) ? str.indexOf('h') : idx;
        idx = (idx == -1) ? str.indexOf('P') : idx;
        idx = (idx == -1) ? str.indexOf('p') : idx;
        idx = (idx == -1 && base != 16) ? str.indexOf('E') : idx;
        idx = (idx == -1 && base != 16) ? str.indexOf('e') : idx;

        char etype = (idx == -1) ? 'N' : str.charAt(idx);

    	  //Когда нет экспоненты (idx + 1) == 0.
        if(idx + 1 == str.length())// no more digits after exponent letter ('12E' or 'FFP')
            return Double.NaN;
    		
    	String exp = null;
    
    		//Отрицательная экспонента, но нет цифр 'E-' or 'P-' or 'h-'
    	if(str.charAt(idx + 1) == '-' && idx != -1 && idx + 2 == str.length())
        	return Double.NaN;
    
    	  //Отрицательная экспонента. 'E-2' or 'p-10'
    	if(str.charAt(idx + 1) == '-' && idx != -1){
         	exp = str.substring(idx + 2);
        	 esign = -1;
     	}
    	else
        exp = str.substring(idx + 1); //Положительная экспонента (после idx следует цифра)

        idx = (idx == -1) ? str.length() : idx; //if no exponent then idx <- length(str)

        String number = str.substring(i, idx);
        return parse(number, exp, etype, base, sign, esign);
	}
}

Данный метод начинает со стандартной проверки на null-значения. Далее, если строка не null и имеет символы, то проверяется её самый первый символ. Если он имеет знак минуса ('-') то множитель sign становится равен (-1). Иначе он остаётся равен 1. После вычисления знака идёт вычисление основания системы счисления по префиксу строки. После обработки префикса, снова проверяется наличие оставшейся части символов в строке. Если больше символов нет, то опять возвращается не-число (NaN). Если префикс основания отсутствует, то основание base считается равным 10. Затем вычисляется экспонента exp числа и индекс idx её начала для её последующего отделения от исходной строки. После вычисления всех компонентов, управление передаётся методу parse().

Заключение

Метод достаточно хорош, но ещё не идеален. При выходе из диапазона значений стандартных типов, можно получить неверный результат (а именно, отрицательные числа, когда как строка представляет положительное число, и наоборот). Он минует исключение NumberFormatException, возвращая не-число NaN когда обнаруживает недопустимый символ (не принадлежащий диапазону цифр в основании) а также NullPointerException, так как есть проверки на null (сводящиеся к замене null на NaN).

Следует также отметить, что самая последняя процедура processNumber(String num) имеет место уже с готовой лексемой num, лишённой лишних пробельных символов. При дублировании знака числа (минуса), результат будет снова NaN. Также, если сама экспонента NaN то и итоговое значение будет NaN. Однако процедура допускает наличие лидирующих нулей вначале числа.

Данную утилиту можно использовать только уже с коллекцией строк (цепочек), заранее выделенных из входного потока.

Комментарии (7)


  1. nordfox
    31.08.2021 04:20
    -1

    Умножаем на -1, не лучший ход. Может abs?


    1. OldNileCrocodile Автор
      31.08.2021 11:14

      Ну так нам надо отрицательное число получить (если заметили знак минуса), а не его абсолютную, положительную величину. Можно, конечно, вместо умножения сделать инверсию битов, и прибавить единицу, например так: num = ~num + 1;


      1. nordfox
        31.08.2021 12:52

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


        1. OldNileCrocodile Автор
          31.08.2021 13:46

          На самом деле задача такая: если число положительное, то положительным оно и останется. Если оно отрицательное, то полученное из строки число надо сделать отрицательным (т.к. метод parseNumber(str, base) не учитывает знак числа). Самый простой способ сделать это прилепить if в конце (что-то вроде):

          if (sign < 0)
          	result = -result;

          Уже исправил в коде.


          1. nordfox
            31.08.2021 13:53

            не очевидно. однозначное решение result = 0-result;


  1. Politura
    31.08.2021 07:06
    +2

    Непонятен юзкейс перевода не десятиных чисел в дабл. То, что приходится время от времени переводить из строк числа разных систем счисления в целочисленные типы это да, а вот в число с плавающей точкой, зачем? Есть какой-нибудь пример, откуда оно такое может прийти?


  1. JekaMas
    31.08.2021 09:55

    Надеюсь, искренне надеюсь, что это не для собеседований.