Советы и рекомендации по написанию и структурированию кода Go
Неправильный способ хранения денег
Поскольку мы имеем дело с долларами и центами (или любыми эквивалентами в вашей местной валюте), и они часто представлены десятичным числом, может показаться очевидным использовать число с плавающей запятой в Go, поскольку они предназначены для представления чисел, содержащих десятичную точку. Однако, если вы немного разберетесь в том, как поплавки работают на аппаратном уровне, вы поймете, почему это не лучший подход.
package main
import "fmt"
func main() {
var sum float32
for i := 0; i < 1_000_000; i++ {
sum += float32(0.2)
}
fmt.Println(sum)
}
Приведенный выше код может привести к неожиданному результату. Это связано с тем, что число 0.2 не может быть точно сохранено во внутренних регистрах памяти нашего компьютера, которые используют двоичную систему счисления для хранения каждого бита цифры: это может только приблизить большинство десятичных дробей. Таким образом, вычисления с использованием чисел с плавающей запятой почти всегда имеют очень малую погрешность, что означает, что вы не можете полагаться на результаты для абсолютной точности.
Возможно, вы захотите прочитать эту статью в документации к языку программирования Python, в которой этот вопрос рассматривается более подробно.
Конечно, элементарная математика говорит нам, что 0,8, умноженное на миллион, всегда должно быть таким же, как 8, умноженное на сто тысяч. Но это не то, на что это похоже, когда мы запускаем приведенный ниже код:
package main
import "fmt"
func main() {
var sum float32
for i := 0; i < 1_000_000; i++ {
sum += float32(0.8)
}
var expectedSum int
for i := 0; i < 100_000; i++ {
expectedSum += int(8)
}
fmt.Println(sum, expectedSum, sum == float32(expectedSum))
}
На точность и абсолютную точность вычислений с использованием целых чисел — при условии, что они не переполняются или не переполняются — можно положиться. Однако мы никогда не сможем чувствовать себя так уверенно, когда используем числа с плавающей запятой. Это правда, что a float64 даст лучшее приближение к истинному результату, чем a float32 (попробуйте изменить тип, используемый в приведенном выше коде, чтобы проверить), потому что в нем больше двоичных цифр для работы, но он не обязательно когда-либо даст абсолютно точный ответ, особенно при выполнении больших и сложных вычислений.
Самый простой способ представления денег, который действительно работает
Итак, теперь, когда мы знаем, что нельзя использовать числа с плавающей запятой, очевидным является использование некоторой формы целого числа. Поскольку целые числа по определению не могут иметь десятичного знака, мы больше не можем хранить значение в долларах, сохраняя при этом центы. Таким образом, нам придется хранить наименьшую денежную единицу, превращая целое число в число наших центов.
32-разрядное целое число без знака может содержать максимальное значение 4 294 967 296, что может составлять более 42,9 миллионов долларов (и 96 центов). Вероятно, этого достаточно для большинства приложений, но поскольку мы не хотим преждевременно ограничивать наши амбиции (в конце концов, Apple сейчас стоит более трех триллионов долларов), давайте вместо этого выберем 64-разрядное целое число, которое может представлять многие миллиарды или даже триллионы долларов — фактически, максимальное значение равно 18 446 744 073 709 551 616, что может представлять сто восемьдесят четыре квадриллиона долларов, что намного больше, чем все деньги, которые в настоящее время находятся в существование во всем мире. Я думаю, этого должно быть достаточно.
package main
import "fmt"
type money uint64
func main() {
var m money = 50032
fmt.Printf("I have %d cents.\n", m)
}
Выше вы можете видеть, как просто хранить количество центов, которые можно добавлять или вычитать, когда это необходимо, точно так же, как мы бы поступили с необработанным целочисленным значением.
Использование структуры
Мы узнали, что если мы должны использовать один тип данных, то лучше хранить наши деньги, используя минимально возможную единицу измерения, то есть центы, но иногда мы можем захотеть явно разделить доллары и центы. Для этого имеет смысл использовать структуру:
package main
import "fmt"
type money struct {
dollars uint64
cents uint8
}
func main() {
var m = money{
dollars: 500,
cents: 32,
}
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
}
Однако одна из проблем такого подхода заключается в том, что он усложняет вычисления. Вы должны помнить о правильном ролловерте центов, чтобы у вас никогда не было больше 99. В приведенном ниже коде вы можете увидеть, как мы сначала складываем все центы и при необходимости конвертируем все лишние центы в доллары:
package main
import "fmt"
type money struct {
dollars uint64
cents uint8
}
func (m *money) Add(dollars uint64, cents uint8) {
deltaCents := uint64(m.cents) + uint64(cents)
if deltaCents > 100 {
m.dollars += deltaCents / 100
m.cents = uint8(deltaCents % 100)
} else {
m.cents = uint8(deltaCents)
}
m.dollars += dollars
}
func main() {
var m = money{
dollars: 500,
cents: 32,
}
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
m.Add(11, 99)
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
}
Важно отметить, что мы должны преобразовать центы в uint64 значения перед выполнением сложения, поскольку a uint8 может содержать не более 256 значений, и кто-то, возможно, дал нам в качестве аргумента нашему Addметоду количество центов, близкое к максимальному, которое переполнится при добавлении наших исходных центов.
Поскольку код несколько сложнее, чем был раньше, у нас явно больше возможностей непреднамеренно вводить ошибки, которые могут испортить наши деньги. Мы должны быть очень осторожны и убедиться, что то, что мы написали, работает так, как мы задумали. Если бы мы писали этот код для производственной среды, было бы чрезвычайно полезно создать несколько модульных тестов, чтобы убедиться, что мы — и другие пользователи — можем доверять ему.
Разрешение одновременного доступа
Мы изменяли поля центов и долларов отдельно, поэтому, если вы собираетесь использовать этот тип данных в параллельных программах, вам также может потребоваться добавить мьютекс в структуру, чтобы случайно не изменить одно из полей между чтением и изменением:
package main
import (
"fmt"
"sync"
)
type money struct {
cents uint64
dollars uint64
mutex sync.RWMutex
}
func NewMoney(dollars, cents uint64) *money {
return &money{
cents: cents,
dollars: dollars,
}
}
func (m *money) Value() (dollars, cents uint64) {
defer m.mutex.RUnlock()
m.mutex.RLock()
dollars, cents = m.dollars, m.cents
return
}
func (m *money) Add(m2 *money) {
dollars2, cents2 := m2.Value()
defer m.mutex.Unlock()
m.mutex.Lock()
m.dollars += dollars2
m.cents += cents2
if m.cents >= 100 {
extraDollars := m.cents / 100
m.cents -= extraDollars * 100
m.dollars += extraDollars
}
}
func main() {
var m = NewMoney(500, 32)
m.Add(NewMoney(25, 99))
fmt.Printf("I have %d dollars and %d cents.\n", m.dollars, m.cents)
}
Это прекрасно работает. Но деньги, которые были добавлены на один баланс, не были сняты с другого: обычно транзакции работают не так! Итак, давайте добавим опцию к Addфункции, которая позволяет переводить деньги с одного счета на другой:
func (m *money) Add(m2 *money, move bool) {
defer m2.mutex.Unlock()
defer m.mutex.Unlock()
m2.mutex.Lock()
m.mutex.Lock()
m.dollars += m2.dollars
m.cents += m2.cents
if m.cents >= 100 {
extraDollars := m.cents / 100
m.cents -= extraDollars * 100
m.dollars += extraDollars
}
if move {
m2.dollars, m2.cents = 0, 0
}
}
func main() {
var myMoney = NewMoney(500, 32)
var yourMoney = NewMoney(25, 99)
fmt.Printf("I used to have %d dollars and %d cents.\n", myMoney.dollars, myMoney.cents)
fmt.Printf("You used to have %d dollars and %d cents.\n", yourMoney.dollars, yourMoney.cents)
myMoney.Add(yourMoney, true)
fmt.Printf("\nI now have %d dollars and %d cents.\n", myMoney.dollars, myMoney.cents)
fmt.Printf("You now have %d dollars and %d cents.\n", yourMoney.dollars, yourMoney.cents)
}
Теперь, если денежная сумма добавляется на один счет, она может быть в то же время удалена с другого счета.
Денежные методы
Однако мы могли бы использовать комбинацию некоторых из предыдущих подходов. Например, мы могли бы хранить центы в uint64 типе и использовать глобальный мьютекс для решения проблем параллелизма:
package main
import (
"fmt"
"sync"
)
type money uint64
var (
globalMoneyMutex sync.Mutex
)
func (m money) Cents() uint64 { return uint64(m) % 100 }
func (m money) Dollars() uint64 { return uint64(m) / 100 }
func NewMoney(dollars, cents uint64) money {
return money((dollars * 100) + cents)
}
func (m *money) Add(m2 *money, move bool) {
defer globalMoneyMutex.Unlock()
globalMoneyMutex.Lock()
*m += *m2
if move {
*m2 = 0
}
}
func main() {
var myMoney = NewMoney(500, 32)
var yourMoney = NewMoney(25, 99)
fmt.Printf("I used to have %d dollars and %d cents.\n", myMoney.Dollars(), myMoney.Cents())
fmt.Printf("You used to have %d dollars and %d cents.\n", yourMoney.Dollars(), yourMoney.Cents())
myMoney.Add(&yourMoney, true)
fmt.Printf("\nI now have %d dollars and %d cents.\n", myMoney.Dollars(), myMoney.Cents())
fmt.Printf("You now have %d dollars and %d cents.\n", yourMoney.Dollars(), yourMoney.Cents())
}
Вы можете видеть, как мы создали описанные выше методы, которые по-прежнему позволяют нам получать доступ к центам и долларам отдельно, используя операторы деления и модуля. Теперь Addметод становится намного проще, поскольку мы можем просто добавлять целочисленные значения без знака, что является огромным преимуществом такого подхода.
Зачем усложнять ситуацию больше, чем это строго необходимо?
Выход на Глобальный уровень
До сих пор мы предполагали, что у каждого в кошельке есть доллары и центы. Это не обязательно так: британцы используют фунты, европейцы - евро, индийцы - рупии, а японцы - иены. Доллар США является мировой резервной валютой, потому что он находится в распоряжении большинства центральных банков по всему миру, но это, конечно, не единственная валюта, которая имеет значение.
Приведенный ниже код позволяет хранить денежные значения в разных валютах. StringМетод выводит значение в естественном формате, используя соответствующий символ. Теперь мы также используем мьютекс с блокировкой чтения, когда мы только получаем доступ, а не изменяем значение, чтобы убедиться, что мы случайно не прочитаем значение, пока кто-то другой его изменяет (sync.RWMutexтип по-прежнему разрешает одновременное выполнение доступа только для чтения, если есть никакой записи сделано не было).
type currency uint8
const (
currencyUSD currency = iota // US dollars
currencyGBP // Great British pounds
currencyEUR // EU Euros
currencyINR // Indian rupees
currencyJPY // Japanese yen
)
type money struct {
value uint64
curr currency
mutex sync.RWMutex
}
func NewMoney(largeUnit, smallUnit uint64, curr currency) *money {
return &money{
value: (largeUnit * 100) + smallUnit,
curr: curr,
}
}
func (m money) LargeUnit() uint64 {
defer m.mutex.RUnlock()
m.mutex.RLock()
return uint64(m.value) % 100
}
func (m money) SmallUnit() uint64 {
defer m.mutex.RUnlock()
m.mutex.RLock()
return uint64(m.value) / 100
}
func (m money) String() string {
defer m.mutex.RUnlock()
m.mutex.RLock()
var builder strings.Builder
switch m.curr {
case currencyUSD:
builder.WriteByte('$')
case currencyGBP:
builder.WriteByte('£')
case currencyEUR:
builder.WriteByte('€')
case currencyINR:
builder.WriteByte('₹')
case currencyJPY:
builder.WriteByte('¥')
}
builder.WriteString(strconv.FormatUint(m.value / 100, 10))
builder.WriteByte('.')
builder.WriteString(strconv.FormatUint(m.value % 100, 10))
return builder.String()
}
func main() {
m := NewMoney(32, 500, currencyUSD)
fmt.Printf("I have %s.\n", m)
}
Однако существует потенциальная проблема с нашей Stringфункцией: вы заметили это?
Если значение нашей маленькой единицы меньше 10, то она не будет печатать обычное количество цифр после десятичных знаков, что выглядит не очень профессионально. Мы действительно хотим, чтобы значение было дополнено нулем, чтобы после запятой всегда было две руны.
Заполнение центов
Устранение этой проблемы - это именно то, что мы делаем в приведенном ниже коде, который добавляет нули к собственному строковому представлению наших меньших единиц, так что строка всегда будет длиной в две руны:
func (m money) String() string {
defer m.mutex.RUnlock()
m.mutex.RLock()
var builder strings.Builder
switch m.curr {
case currencyUSD:
builder.WriteByte('$')
case currencyGBP:
builder.WriteByte('£')
}
builder.WriteString(strconv.FormatUint(m.value / 100, 10))
builder.WriteByte('.')
smallUnits := strconv.FormatUint(m.value % 100, 10)
if l := len(cents); l < 2 {
for i := 0; i < l; i++ {
builder.WriteByte('0')
}
}
builder.WriteString(smallUnits)
return builder.String()
}
Однако мы можем упростить приведенное выше условие: мы знаем, что strconv.FormatUintфункция никогда не вернет пустую строку, поэтому, если длина ее возвращаемого значения меньше двух, мы можем сделать вывод, что оно должно быть равно единице. Таким образом, мы можем изменить if оператор и for полностью удалить цикл, например:
if len(cents) == 1 {
builder.WriteByte('0')
}
Конвертация валют с использованием данных в режиме реального времени
Теперь мы знаем, как обращаться с международными валютами, но у нас пока нет возможности конвертировать между ними.
Чтобы сделать это, нам потребуется доступ к данным, которые обеспечивают правильные коэффициенты конвертации, а для доступа к этим данным нам понадобится какой-то стандартизированный способ уникальной идентификации каждой валюты. Приведенный ниже код показывает, как мы можем создать строковые идентификаторы для двух валют (конечно, при необходимости их можно добавить позже).:
type currency uint8
const (
currencyUSD currency = iota
currencyGBP
)
func (c currency) String() string {
switch c {
case currencyUSD:
return "USD"
case currencyGBP:
return "GBP"
}
return ""
}
Теперь, когда у нас есть уникальные идентификаторы, мы можем получить доступ к общедоступному API, чтобы получить недавно обновленные курсы конвертации валют, добавив идентификатор базовой валюты к строке запроса в URL. API возвращает объект JSON, который предоставляет необходимые нам данные. Нам просто нужно разделить текущее значение на соответствующее число для наших двух валют:
func (m *money) ConvertCurrency(curr currency) error {
defer m.mutex.Unlock()
m.mutex.Lock()
oldCurr := m.curr
if curr == oldCurr {
return nil
}
res, err := http.Get("https://api.exchangerate.host/latest?base=" + oldCurr.String())
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("status code %d: %s", res.StatusCode, res.Status)
}
var result convertCurrencyResult
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return err
}
rate, foundRate := result.Rates[curr.String()]
if !foundRate {
return fmt.Errorf(
"cannot load currency data for converting from %s to %s currency",
oldCurr.String(),
curr.String(),
)
}
m.value = uint64(float64(m.value) / rate)
m.curr = curr
return nil
}
func main() {
m := NewMoney(32, 500, currencyUSD)
fmt.Printf("I used to have %s.\n", m)
if err := m.ConvertCurrency(currencyGBP); err != nil {
panic(err)
}
fmt.Printf("I then had %s.\n", m)
if err := m.ConvertCurrency(currencyUSD); err != nil {
panic(err)
}
fmt.Printf("I now have %s.\n", m)
}
Код должен выдавать выходные данные, которые выглядят примерно так:
I used to have $500.32.
I then had have £660.92.
I now have $500.31.
Обратите внимание, что конечная стоимость в долларах США не совсем совпадает с той, с которой мы начали: разница между 500,32 и 500,31 доллара составляет один цент. Это всего лишь ошибка округления, и это никогда не должно привести к тому, что мы потеряем больше одного цента.
Поскольку мы преобразуем a float64 в uint64 тип, мы всегда будем стремиться округлять наше конечное значение в меньшую сторону (даже если мы не используем явную функцию, напримерmath.Floor). Любая десятичная информация будет потеряна при преобразовании значения в целое число. Это неплохо, потому что это означает, что если мы ведем коммерческий бизнес, который позволяет нашим клиентам переключаться между валютами, мы никогда не останемся без средств.
Включая сборы за Конвертацию
Однако в реальном мире с клиента может взиматься дополнительная плата за конвертацию валюты, поскольку процесс покупки и продажи различных валют сопряжен с транзакционными издержками.
Предположим, например, что конвертация из одной валюты в другую всегда стоит 2,5% от вашего баланса. В этом случае мы можем установить глобальной константе convertCurrencyPercentageChargeзначение 2.5 и изменить вычисление значения в конце ConvertCurrencyметода на строки ниже:
percentage := (1 - (float64(convertCurrencyPercentageCharge) / 100))
m.value = uint64(float64(m.value) * percentage / rate)
Когда вы запустите программу, теперь вы увидите, что ваша стоимость в долларах США заметно снизилась, даже если она будет конвертирована обратно:
I used to have $500.32.
I then had £644.39.
I now have $475.61.
Это должно отбить у людей охоту совершать конверсии слишком часто.
Кража Колеса
Мы изучили, как создавать типы данных, которые могут обрабатывать повседневные денежные транзакции, но зачем изобретать велосипед, когда другие люди уже проделали эту работу? Существует множество доступных пакетов Go, которые будут управлять действиями, стоящими за хранением денег для вас. Один из самых простых и удобных в использовании — это "go-money", который предоставляет все функции, которые мы обсуждали выше (за исключением конвертации валют, которую вы теперь можете реализовать самостоятельно, если это необходимо).
В приведенном ниже примере показано, как сложить два денежных значения, а затем распечатать результат и некоторую базовую информацию о нем:
package main
import (
"fmt"
"github.com/Rhymond/go-money"
)
func main() {
oneEuro := money.New(100, money.EUR)
twoEuros := money.New(200, money.EUR)
totalEuros, err := oneEuro.Add(twoEuros)
if err != nil {
panic(err)
}
fmt.Println(totalEuros.Display()) // €3.00
fmt.Println(totalEuros.IsZero()) // false
fmt.Println(totalEuros.Negative().Display()) // -€3.00
fmt.Println(
totalEuros.SameCurrency(oneEuro) &&
totalEuros.SameCurrency(twoEuros),
) // true
}
Заключительные мысли
Самое важное, что можно извлечь из этого поста в блоге, - это то, что вы никогда не должны использовать число с плавающей запятой, чтобы попытаться представить стоимость денег в Go. Решите ли вы использовать простое целое число или структуру с дополнительной информацией, полностью зависит от потребностей вашего проекта — например, будете ли вы включать параллельные программы или внедрять функцию для конвертации между разными валютами.