5 правил работы с суммами или live example к разговору о множестве чисел с плавающей точкой

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

Не используйте double

О том, что для хранения сумм нельзя использовать двоичный тип с плавающей точкой одинарной точности float, знают все. Однако распространено мнение, что вместо float можно использовать double. Между тем double не намного лучше.

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

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

599.99

Код на Java
	float f = 599.99f;
	System.out.printf("float %s%n", new BigDecimal(f).setScale(6, BigDecimal.ROUND_DOWN));
	double d = 599.99d;
	System.out.printf("double %s%n", new BigDecimal(d).setScale(15, BigDecimal.ROUND_DOWN))

Код на C#
	float f = 599.99f;
	float fi = (long) f;
	float fp = f - fi;
	long fil = (long) fi;
	long fpl = (long) (fp * 1000000);
	Console.WriteLine("float {0}.{1}", fil, fpl);
	double d = 599.99d;
	double di = Math.Truncate(d);
	double dp = d - di;
	long dil = (long) di;
	long dpl = (long) (dp * 1000000000000000);
	Console.WriteLine("double {0}.{1}", dil, dpl);

float 599.989990
double 599.990000000000009

То есть программа еще не сделала никаких вычислений, просто сохранила сумму в локальных переменных в двоичном формате, а в ней уже потеряна точность. В случае использования float — в 6-м знаке, в случае с double — в 15-м.

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

Гарантировать, что программа на любых объемах будет выдавать точный результат в таких условиях практически невозможно. И double может наоборот усугубить проблему, так как на нем заметить ошибку будет сложнее.

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

z = 599.99 руб. - 0.98 руб. - 599.00 руб. - 0.01 руб.

Код на Java
	double z = ((599.99d - 0.98d) - 599.00d) - 0.01d;
	if (z == 0d) {
		System.out.println("z == 0");
	} else if (z > 0d){
		System.out.println("z > 0");
	} else {
		System.out.println("z < 0");
	}

Код на C#
	double z = ((599.99d - 0.98d) - 599.00d) - 0.01d;
	if (z == 0d)
	{
		Console.WriteLine("z == 0");
	}
	else if (z > 0d)
	{
		Console.WriteLine("z > 0");
	}
	else
	{
		Console.WriteLine("z < 0");
	}

Результат
z < 0

Вместо double надо использовать представление числа на основе степеней с основанием 10. В Java для этого предусмотрен тип BigDecimal, в C# — decimal.

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

Сумма не может быть отрицательной

Количество денег не может быть меньше 0. Это может показаться странным для программиста в момент, когда он пишет код, но когда тот же программист пойдет в магазин, для него аксиома неотрицательности сумм будет очевидна. Ни разу при осуществлении покупок ни один покупатель не сказал кассиру: «Я вам должен еще минус сто рублей», — вместо: «Дайте мне сдачу сто рублей».

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

Компания, оказывающая услуги ЖКХ, создала веб-сервис, который по номеру лицевого счета возвращает сумму к оплате. Коммерческий банк реализовал сервис оплаты этих услуг на своем сайте. Плательщик в прошлом месяце заплатил за квартиру с запасом. Он заходит в свой личный кабинет в интернет-банке, вводит лицевой счет, и банк предлагает ему провести платеж на -1500 рублей. При попытке совершить данную операцию пользователю сообщают об ошибке, так как сумма платежа должна быть положительной. Несчастный пользователь думает, что у него задолженность, поскольку очень часто в счетах задолженности обозначают со знаком минус, поэтому он исправляет сумму и совершает платеж. Теперь у него переплата 3000 рублей. На самом деле сумма счета может быть только положительной. Вместо отрицательной задолженности в счете должно было быть указано, что клиент должен оплатить ноль рублей, а сумма переплаты должна идти отдельной графой.

Не так давно в новостях много рассказывалось о так называемом «техническом овердрафте», который якобы образовался на карточных счетах клиентов в одном российском банке. Могу предположить, как это произошло. У клиента на счете не было денег, подошло время списания средств за обслуживание счета, банк сделал проводку со счета клиента на свой счет доходов, в результате чего на счете образовался подозрительный отрицательный баланс. Клиент, конечно, больше никогда не прикоснется к такому счету, не станет больше пополнять его, так как по правилам сложения сумма его перевода сложится с отрицательным остатком на счете, и он потеряет часть своих денег. За клиента в этом плане можно не беспокоиться, а вот банк в таком случае имеет на своем счету доходов виртуальные средства. Если он их потратит на хозяйственную деятельность, то в итоге у него в балансе образуется дыра. А все потому, что на счет доходов записали средства, которых у клиента не было. Вместо этого надо было сделать совсем другую проводку, со счета задолженностей клиента на счет невыполненных обязательств. А поскольку затронуты ошибкой были некредитные карты, то такую проводку физически невозможно было сделать.

Здесь наглядно видно, что при смене знака сумма сразу меняет свое смысловое значение. Легко заметить, что, позволив суммам уходить в минус, разработчик открывает ящик пандоры, из которого могут вылететь неограниченные убытки для обслуживаемого бизнеса. Лучше заранее проверить сумму на неотрицательность и обезопасить систему от неожиданных финансовых потерь.

Заведите класс, содержащий сумму и категорию. Категория должна показывать назначение данной суммы: остаток на счете, цена, переплата, задолженность, обороты, сальдо и т. д. Как только в результате какой-либо операции возникает сумма меньше нуля, сразу выбрасывайте исключение или меняйте категорию суммы. Как правило, для полноценного учета хватает двух категорий — дебет и кредит.

На мой взгляд, выписки по счету, которые предоставляет большинство банков, выглядят нелепо. Что значит покупка на минус две пятьсот? Что значит расходы минус двадцать тысяч? По логике это должно означать, как будто у держателя счета что-то купили или он что-то заработал. Если перед вами стоит задача сделать выписку, то сделайте в выписке 2 столбца, один для списаний, другой для зачислений, и пишите нормально: «покупка в супермаркете 3500 ...», «зарплата… 20000» «всего списаний столько-то, зачислений столько-то».

Сумма — это не только число, но и валюта

Еще в средней школе нас всех учили, что в формулах надо указывать размерность величин и проверять ее. Килограммы нельзя складывать с метрами или сравнивать с литрами. Прежде чем умножить скорость на время, надо убедиться, что скорость указана в м/c, а время в секундах. Но при работе с суммами об этом часто забывают. Сумма — это физическая величина, и она имеет размерность. Поэтому надо работать с нею так, как учат на уроках физики.

Если система имеет дело исключительно с одной национальной валютой, то ее можно опустить, однако так бывает очень редко. Даже в пределах одной страны зачастую в ходу сразу несколько валют. Например, белорусский рубль до деноминации имеет обозначение BYR, а после деноминации — BYN. Если вы их перепутаете, это будет в 10 раз хуже, чем сложить метры с миллиметрами, потому что 1 BYN = 10 000 BYR. А еще говорят был BYB, для которого 1 BYR = 1000 BYB. Тем более важно контролировать размерность сумм в системе, которая работает с валютами разных стран.

Храните в базе данных и в памяти сумму вместе с валютой. Заведите для этого специальное свойство в классе, представляющем сумму. При выполнении операций контролируйте размерность и в случае ее нарушения выбрасывайте исключение. В качестве идентификатора валюты я обычно использую 3-символьный код ISO-4217.

Не используйте понятия «покупка» и «продажа»

Термины «покупка» и «продажа» — это бытовые понятия, которыми оперируют люди при выполнении повседневных задач. Люди ориентируются на систему координат, в которой они находятся в начальной точке отсчета. В финансах же любая сделка одновременно является и покупкой, и продажей.

Наберите в Яндексе «купить автомобиль», и половина ссылок в выдаче будет на объявления о продаже автомобилей, а другая — на объявления о покупке. То же самое будет, если поискать по запросу «аренда квартир», «сдать», «снять» и т. п.

Особенно наглядно такая двойственность проявляется при обмене валюты. Банки на своих сайтах предлагают курсы покупки и продажи различных валют. Как понять, что означает курс покупки доллара 56.61 руб.? Это банк по такой цене продает доллары или клиент рубли покупает? На самом деле пользователи, меняющие валюту, помнят, что 1 доллар стоит примерно пятьдесят с чем-то рублей, и что банк меняет валюту таким образом, чтобы получить прибыль, поэтому, когда надо обменять доллары на рубли, они смотрят на меньшую сумму в рублях, а когда надо обменять рубли на доллары — на большую. При этом, как правило, понять, что означают надписи «Покупка» или «Продажа» над цифрами, никто даже не пытается. Еще общая практика заключается в том, что меньшая сумма пишется слева, а большая — справа. Если написать одно предложение «Курс покупки израильского шекеля 15.75 руб.», мало кто угадает, что имеется в виду.

Когда разработчик пишет код, у него перед глазами нет конкретных значений, нет таблицы, где привычные цифры расположены в определенных местах, поэтому он ориентируется только на названия своих переменных. Если использовать названия типа buySum, sellSum, buyPrice, sellPrice и т. д., можно запросто перепутать обменные курсы. Такая же ситуация возможна и у сотрудников, которые эти курсы будут забивать в справочники.

Что будет, если клиент обнаружит, что ему предлагают обменять рубли на доллары по курсу 56 долларов за рубль? Он не будет писать в поддержку, он не станет жаловаться в фейсбуке, не будет скринить страницы. Предприимчивый клиент, скорее всего, поменяет все имеющиеся у него рубли и быстренько снимет все, что наменял.

Помните, что-то подобное уже было в новостях? Там, скорее всего, были нарушены 2 правила: перепутаны покупка с продажей и допущена отрицательная курсовая разница.

При автоматизации сделок вместе с понятиями «покупка» или «продажа» должны быть уточняющие слова типа «покупка клиентом» или «продажа банком». Но если вы автоматизируете биржу, то у вас и продавец, и покупатель — оба клиенты. Поэтому лучше всего использовать нейтральные понятия «сделка», «обмен», «дебет», «кредит», «зачисление», «списание» и т. д.

Обменный курс — это вектор из двух сумм

Примерно так выглядит стандартное предложение обмена валют для клиента:

Как оно должно быть реализовано в коде? Курс выглядит как сумма, поэтому к нему должны быть применимы общие правила.

Десятичное представление — OK.
Неотрицательное значение — OK.
Размерность —?

Давайте попробуем определить, какая размерность у тех цифр, которые обычно горят на табло обменных пунктов. На первый взгляд кажется, что там везде рубли, но какие операции мы можем с ними сделать? Можно ли сложить курс покупки доллара 55.61 руб. с остатком на счете 1500.00 руб.? Нельзя, потому что настоящая размерность обменного курса представляет собой руб./доллар или, еще точнее, валюта-1/валюта-2.

А еще можно вспомнить, что для «удобства» в некоторых валютах используются коэффиценты. Например, для йены используется курс за 100 единиц валюты. Поэтому под суммой в 1-й валюте есть еще коэффициент во 2-й валюте.

Получается, размерность — валюта-1/(валюта-2 * коэффициент)?

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

В случае с «курсом покупки» клиент дает банку 1$ и получает 56.61₽.
В случае с «курсом продажи» клиент дает банку 58.79₽ и получает 1$.

То есть у левого числа размерность ₽/$, а у правого — $/₽.

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

Только не надо делить 100 йен на 52.79 рублей, так как получившееся число придется округлить, и потеряется точность.

В итоге, с учетом всех нюансов, курс обмена будет выглядеть как-то так:

	public final class ExchangeRate {
		public static final int PRECISION = 4;
		private final Sum clientGives;
		private final Sum clientTakes;
		public ExchangeRate(Sum clientGives, Sum clientTakes) {
			this.clientGives = clientGives;
			this.clientTakes = clientTakes;
		}
	 
		public Sum exchange(Sum sum) {
			if (!sum.getCurrency().equals(clientGives.getCurrency())) {
				throw new IllegalArgumentException();
			}
			BigDecimal amount = sum.getAmount().mulitply(clientTakes.getAmount())
					.divide(clientGives.getAmount(), PRECISION, BigDecimal.ROUND_HALF_UP);
			return new Sum(amount, clientTakes.getCurrency());
		}
	}

Источник: Habrahabr Сергей Б. @sergey-b


Нажми, если нравится

Автор

Вадим Валентинович Костерин

Директор Инженерного центра корпоративных информационных систем Высшей школы экономики и управления НИУ Южно-Уральский государственный университет. Лауреат ВДНХ, награждён серебряной медалью. Лауреат Всероссийских, международных и региональных (1998–2015 гг.) выставок, за множеством которых перечисление потеряло смысл.

Добавить комментарий