Как стать автором
Обновить

Пишем калькулятор на C++ с SFML

Время на прочтение11 мин
Количество просмотров10K

Привет, коллеги и доброжелательные критики! Сегодня я решил отвлечься от своей громоздкой работы, чтобы написать что-то простое, но с изюминкой — калькулятор с графическим интерфейсом на C++20 и SFML. Этот проект — не претензия на что-то грандиозное, а скорее лёгкий эксперимент, чтобы вспомнить, как приятно писать код, который сразу видно на экране. Заодно я поделюсь с вами своими мыслями, подходами и парой советов. Давайте разберём, как я это закрутил и почему выбрал именно SFML.

Почему калькулятор? И почему SFML?

Калькулятор — это классика программирования. Помню, как в начале карьеры, ещё в нулевых, писал такие на Pascal для курсовых, потом переделывал их на C с самописным парсером для зачётов. Это как "Hello, World", только с кнопками и математикой — отличный способ проверить свои навыки. Сейчас, конечно, можно было бы взять что-то посерьёзнее: Qt для полноценного GUI, SDL для низкоуровневого контроля или даже Unreal Engine, если уж совсем размахнуться. Но я остановился на SFML, а конкретно на версии 2.6.1. Почему именно она? Это последняя стабильная версия на март 2025 года, и она отлично дружит с современными компиляторами — GCC 12, MSVC 2022 и даже Clang 17. В ней есть мелкие улучшения рендеринга, поддержка C++17/20 из коробки и никаких сюрпризов с совместимостью, что для меня как человека без дополнительного запаса времени очень важно — не люблю тратить своё драгоценное время на борьбу с зависимостями.

SFML я выбрал не просто так. Это лёгкая библиотека для 2D-графики, которая не заставляет тебя писать тонны boilerplate-кода, как Qt, или возиться с низкоуровневыми деталями, как SDL. Сравните с другими системами: Qt — это тяжеловес с кучей возможностей, но для калькулятора его функционал избыточен, как если бы вы использовали танк для поездки в магазин. SDL даёт больше контроля, но требует больше ручной работы — например, самому рисовать текстуры или управлять контекстом OpenGL. SFML же сразу предлагает готовые примитивы вроде RectangleShape, Text и Sprite, что идеально для простого GUI. Её плюсы: минимализм (быстро настраивается), производительность (для 2D почти не жрёт ресурсов), кроссплатформенность (Windows, Linux, macOS без лишних телодвижений). Почему не SFML 3.1, потому - что синтаксис поменялся и нужно потратить время, чтобы вникнуть, а времени увы очень мало. Может в будущем я и буду писать код на SFML 3.1, но точно не сейчас. Да и багов хватает всегда с выходом чегото нового.

Я использую SFML для небольших экспериментов, где нужна быстрая визуализация: прототипы интерфейсов, простые инструменты для отладки, визуализации данных или даже мелкие игрушки для души. Например, пару лет назад я делал простой шутер - "Кощей", рендерил тысячи клеток в реальном времени, и она справилась без лагов. Для больших проектов я бы взял Qt или Unity, но тут задача была другая — сделать что-то рабочее за пару часов, без оверхеда и с удовольствием.

Архитектура: как я это разложил

Проект у меня получился из трёх основных классов плюс точка входа. Я старался держать всё просто, но с учётом современных практик — никаких сырых указателей, никаких C-style массивов. Вот что вышло:

Button — класс для кнопок. Простая обёртка над прямоугольником и текстом, но с умными указателями для управления памятью.

Calculator — главный класс, который собирает интерфейс, обрабатывает клики и связывает всё с вычислениями.

ExpressionEvaluator — рекурсивный парсер выражений. Без него это был бы просто красивый блокнот с кнопками.

main.cpp — минимальный код для запуска окна и цикла событий.

Я сразу решил использовать std::unique_ptr вместо сырых указателей — в 2025 году это уже стандарт, и возиться с new/delete нет никакого смысла. Также заменил все массивы на std::vector — меньше багов, больше читаемости. Давайте разберём каждый кусок подробнее.

Кнопки (Button.h)

#pragma once
#include <string>
#include <memory>
#include <SFML/Graphics.hpp>

class Button : public sf::Drawable, public sf::Transformable {
public:
    Button(std::string text, sf::Font& font, unsigned int characterSize, 
           sf::Vector2f position, sf::Vector2f size)
        : m_rect{std::make_unique<sf::RectangleShape>(size)}, // Создаем прямоугольник кнопки
          m_text{std::make_unique<sf::Text>(text, font, characterSize)} { // Создаем текст на кнопке
        
        m_rect->setPosition(position); // Задаем позицию кнопки
        m_rect->setFillColor(sf::Color(200, 200, 200)); // Серый фон
        m_rect->setOutlineColor(sf::Color::Black); // Черная обводка
        m_rect->setOutlineThickness(2); // Толщина обводки

        m_text->setPosition(position.x + 20, position.y + 20); // Текст с отступом внутри кнопки
        m_text->setFillColor(sf::Color::White); // Белый цвет текста
    }

    void pressEffect() { // Эффект нажатия — меняем цвет
        m_rect->setFillColor(sf::Color(150, 150, 150));
        m_rect->setOutlineColor(sf::Color(150, 150, 150));
    }

    void releaseEffect() { // Эффект отпускания — возвращаем исходный цвет
        m_rect->setFillColor(sf::Color(200, 200, 200));
        m_rect->setOutlineColor(sf::Color::Black);
    }

    std::string getText() const { // Получаем текст кнопки
        return m_text->getString();
    }

    sf::FloatRect getGlobalBounds() const { // Границы кнопки с учетом трансформаций
        return getTransform().transformRect(m_rect->getGlobalBounds());
    }

private:
    void draw(sf::RenderTarget& target, sf::RenderStates states) const override { // Отрисовка кнопки
        states.transform *= getTransform();
        target.draw(*m_rect, states); // Рисуем прямоугольник
        target.draw(*m_text, states); // Рисуем текст
    }

    std::unique_ptr<sf::RectangleShape> m_rect; // Умный указатель на прямоугольник
    std::unique_ptr<sf::Text> m_text; // Умный указатель на текст
};

Класс кнопки — это мой первый шаг к интерфейсу. Он простой, но функциональный: прямоугольник с текстом, который реагирует на клики. Использовал std::unique_ptr, чтобы памятью управляла сама программа — никаких утечек, никакого ручного delete. Добавил визуальную обратную связь через pressEffect и releaseEffect — кнопка темнеет при нажатии, что делает UI живым. Цвета выбрал на глаз: серый фон и белый текст — классика, но в реальном проекте я бы вынес их в константы или конфиг-файл, чтобы дизайнеры могли играться. SFML тут хорош тем, что сразу даёт RectangleShape и Text — не надо самому писать шейдеры или возиться с текстурами, как в SDL. Ещё я подумал центрировать текст поумнее, но отступ в 20 пикселей для демки сгодился.

Калькулятор (Calculator.h)

#pragma once
#include <string>
#include <vector>
#include <memory>
#include <SFML/Graphics.hpp>
#include "Button.h"
#include "ExpressionEvaluator.h"

class Calculator : public sf::Drawable, public sf::Transformable {
public:
    explicit Calculator(sf::Font& font) { // Конструктор принимает шрифт
        display = std::make_unique<sf::RectangleShape>(sf::Vector2f(360, 50)); // Создаем дисплей
        display->setPosition(20, 20); // Позиция дисплея
        display->setFillColor(sf::Color(173, 216, 230)); // Голубой фон
        display->setOutlineColor(sf::Color::Black); // Черная обводка
        display->setOutlineThickness(2); // Толщина обводки

        displayText = std::make_unique<sf::Text>("", font, 30); // Текст на дисплее
        displayText->setPosition(30, 30); // Позиция текста
        displayText->setFillColor(sf::Color::Black); // Черный цвет текста

        windowBackground = std::make_unique<sf::RectangleShape>(sf::Vector2f(400, 600)); // Фон окна
        windowBackground->setFillColor(sf::Color::White); // Белый цвет фона

        const std::vector<std::string> labels = { // Список меток для кнопок
            "7", "8", "9", "/", "4", "5", "6", "*", "1", "2", "3", "-",
            "0", "C", "=", "+", "(", ")", "<<<"
        };

        buttons.reserve(labels.size()); // Резервируем место под кнопки
        for (size_t i = 0; i < labels.size(); ++i) { // Создаем кнопки в цикле
            buttons.emplace_back(std::make_unique<Button>(
                labels[i], font, 24,
                sf::Vector2f(20 + (i % 4) * 90, 100 + (i / 4) * 90),
                sf::Vector2f(80, 80)
            ));
        }
    }

    void handleEvent(const sf::Event& event, sf::RenderWindow& window) { // Обработка событий
        if (event.type == sf::Event::MouseButtonPressed && 
            event.mouseButton.button == sf::Mouse::Left) {
            for (const auto& button : buttons) {
                if (button->getGlobalBounds().contains(
                    static_cast<sf::Vector2f>(sf::Mouse::getPosition(window)))) {
                    button->pressEffect(); // Анимация нажатия
                    processInput(button->getText()); // Обрабатываем ввод
                    displayText->setString(input); // Обновляем дисплей
                }
            }
        }
        if (event.type == sf::Event::MouseButtonReleased && 
            event.mouseButton.button == sf::Mouse::Left) {
            for (const auto& button : buttons) {
                button->releaseEffect(); // Возвращаем цвет
            }
        }
    }

private:
    void draw(sf::RenderTarget& target, sf::RenderStates states) const override { // Отрисовка калькулятора
        states.transform *= getTransform();
        target.draw(*windowBackground, states); // Рисуем фон
        target.draw(*display, states); // Рисуем дисплей
        target.draw(*displayText, states); // Рисуем текст
        for (const auto& button : buttons) {
            target.draw(*button, states); // Рисуем все кнопки
        }
    }

    void processInput(const std::string& text) { // Логика обработки ввода
        using namespace std::string_literals;
        if (text == "C"s) { // Очистка
            input.clear();
        }
        else if (text == "="s) { // Вычисление результата
            try {
                double result = ExpressionEvaluator::evaluate(input);
                input = std::to_string(result);
                if (input.ends_with(".000000")) { // Убираем лишние нули
                    input = input.substr(0, input.find('.'));
                }
            }
            catch (const std::exception&) {
                input = "Error"s; // Ошибка при вычислении
            }
        }
        else if (text == "<<<"s) { // Удаление последнего символа
            if (!input.empty()) {
                input.pop_back();
            }
        }
        else if (text == "+"s || text == "-"s || text == "*"s || text == "/"s) { // Операторы
            std::string_view ops = "+-*/";
            if (!input.empty() && ops.find(input.back()) == std::string_view::npos && input.length() < 19) {
                input += text;
            }
        }
        else if (input.length() < 19) { // Добавляем символ, если не превышен лимит
            input += text;
        }
        displayText->setString(input); // Обновляем текст на дисплее
    }

    std::unique_ptr<sf::RectangleShape> windowBackground; // Фон окна
    std::unique_ptr<sf::RectangleShape> display; // Дисплей
    std::unique_ptr<sf::Text> displayText; // Текст дисплея
    std::vector<std::unique_ptr<Button>> buttons; // Вектор кнопок
    std::string input; // Текущий ввод
};

Это ядро всего проекта. Я долго думал, как организовать кнопки, и решил остановиться на сетке 4x5 — это стандартная раскладка, как на старых калькуляторах Casio, только с парой дополнительных кнопок вроде скобок и "backspace". Размеры окна (400x600) и кнопок (80x80) подбирал вручную, чтобы всё аккуратно влезло, а отступы в 20 пикселей между элементами добавил для читаемости. Дисплей сделал голубым — просто захотелось чего-то яркого на белом фоне. В processInput вся логика ввода: защита от двойных операторов (чтобы не вводились "++" или "*/"), лимит в 19 символов (SFML начинает обрезать текст, если больше), плюс обработка ошибок вроде деления на ноль. Использовал ends_with из C++20 — мелочь, но избавляет от ручной проверки концов строки. SFML тут хорош своей простотой: метод draw сам рендерит всё в нужном порядке, и мне не пришлось писать сложную логику обновления.

Ещё я заметил, что при быстрых кликах SFML немного подтормаживает — это не баг самой библиотеки, а особенность того, как я обрабатываю события. В реальном проекте я бы добавил дебаунсинг или перерисовывал только изменённые элементы, но для демки оставил как есть.

Парсер (ExpressionEvaluator.h)

#pragma once
#include <string>
#include <string_view>
#include <stdexcept>
#include <charconv>

class ExpressionEvaluator {
public:
    static double evaluate(std::string_view expression) { // Вычисляем выражение
        size_t pos = 0;
        return parseExpression(expression, pos);
    }

private:
    static double parseExpression(std::string_view expr, size_t& pos) { // Разбираем выражение (+, -)
        double result = parseTerm(expr, pos);
        while (pos < expr.length()) {
            char op = expr[pos];
            if (op != '+' && op != '-') break;
            pos++;
            double term = parseTerm(expr, pos);
            result = (op == '+') ? result + term : result - term;
        }
        return result;
    }

    static double parseTerm(std::string_view expr, size_t& pos) { // Разбираем члены (*, /)
        double result = parseFactor(expr, pos);
        while (pos < expr.length()) {
            char op = expr[pos];
            if (op != '*' && op != '/') break;
            pos++;
            double factor = parseFactor(expr, pos);
            if (op == '*') result *= factor;
            else if (factor == 0) throw std::invalid_argument("Деление на ноль!");
            else result /= factor;
        }
        return result;
    }

    static double parseFactor(std::string_view expr, size_t& pos) { // Разбираем множители (числа, скобки)
        skipWhitespace(expr, pos);
        if (pos >= expr.length()) throw std::invalid_argument("Некорректное выражение");

        if (expr[pos] == '(') { // Обработка скобок
            pos++;
            double result = parseExpression(expr, pos);
            skipWhitespace(expr, pos);
            if (pos >= expr.length() || expr[pos] != ')') 
                throw std::invalid_argument("Нет закрывающей скобки");
            pos++;
            return result;
        }

        double result{};
        auto [ptr, ec] = std::from_chars(expr.data() + pos, // Парсим число
                                       expr.data() + expr.length(), 
                                       result);
        if (ec != std::errc()) throw std::invalid_argument("Некорректное число");
        pos = ptr - expr.data();
        return result;
    }

    static void skipWhitespace(std::string_view expr, size_t& pos) { // Пропускаем пробелы
        while (pos < expr.length() && std::isspace(expr[pos])) pos++;
    }
};

Парсер — это, пожалуй, самая интересная часть. Я решил сделать рекурсивный спуск. Алгоритм простой, но правильный: сначала парсим скобки и числа (через parseFactor), потом умножение и деление (в parseTerm), и только потом сложение с вычитанием (в parseExpression). Это автоматически учитывает приоритет операций, так что "2 + 3 * 4" даст 14, а не 20. Использовал std::from_chars вместо stringstream — это быстрее и меньше тянет за собой STL-оверхеда. Плюс, std::string_view экономит копирования строк, что для парсера мелочь, но приятная.

Я добавил поддержку пробелов через skipWhitespace, хотя в этом интерфейсе она не особо нужна — просто привычка писать код с запасом на будущее. Ещё парсер выбрасывает исключения при ошибках вроде деления на ноль или незакрытых скобок — в реальном проекте я бы добавил нормальное логирование, но тут просто вывожу "Error" на дисплей. SFML тут не участвует, но без парсера калькулятор был бы просто красивой оболочкой.

Точка входа (main.cpp)

#include <SFML/Graphics.hpp>
#include <iostream>
#include "Calculator.h"

int main() {
    sf::RenderWindow window(sf::VideoMode(400, 600), L"SFML Калькулятор"); // Создаем окно
    auto font = std::make_unique<sf::Font>(); // Загружаем шрифт
    if (!font->loadFromFile("arialmt.ttf")) {
        std::cerr << "Не удалось загрузить шрифт!\n";
        return -1;
    }

    Calculator calculator(*font); // Создаем калькулятор

    while (window.isOpen()) { // Главный цикл
        sf::Event event;
        while (window.pollEvent(event)) {
            if (event.type == sf::Event::Closed) window.close(); // Закрытие окна
            calculator.handleEvent(event, window); // Обработка событий
        }

        window.clear(); // Очистка экрана
        window.draw(calculator); // Отрисовка калькулятора
        window.display(); // Показываем результат
    }
    return 0;
}

Точка входа — это стандартный цикл SFML. Окно 400x600 выбрал как компромисс между компактностью и читаемостью. Шрифт взял Arial, потому что он универсален и не требует возни с лицензиями — в продакшене я бы добавил fallback на системный шрифт через sf::Font::loadFromMemory или проверку через std::filesystem. SFML тут стабильно рендерит всё без сюрпризов, хотя я заметил, что при частых кликах FPS может проседать — это не баг библиотеки, а моя реализация без оптимизаций.

Рефлексия: что получилось и что можно лучше

Проект занял у меня пару часов в субботу вечером, и результат меня приятно удивил. Калькулятор считает выражения вроде "2 + 3 * (4 - 1)" (правильно выдаёт 11), ловит ошибки вроде деления на ноль или незакрытых скобок, и выглядит аккуратно. SFML показала себя с лучшей стороны: рендеринг быстрый, API интуитивный, никаких глюков с памятью или шрифтами. C++20 тоже не подвёл: std::string_view экономит копирования, ends_with упрощает обработку строк, а std::from_chars делает парсинг чисел шустрым.

Нет предела совершенству, что можно улучшить:

Десятичные числа. Сейчас парсер понимает только целые — надо добавить поддержку точек и, возможно, научные форматы вроде "1.23e-4".

Производительность. Для коротких выражений мой рекурсивный спуск норм, но на длинных строках (например, 100 операторов) лучше взять стековый алгоритм или подключить Boost.Spirit. Я даже прикинул, как это сделать, но для демки оставил как есть.

UI/UX. Клавиатурный ввод был бы логичным дополнением — сейчас только мышью тыкать. Ещё можно добавить масштабируемость окна, тёмную тему или анимации переходов между состояниями.

Тестирование. Я писал на коленке, но в реальном проекте нужны юнит-тесты для парсера — хотя бы на базовые случаи вроде "2+2", "1/0" и "(2+3)*4".

Логирование. Ошибки сейчас просто пишут "Error" на дисплей. В продакшене я бы вывел их в файл или консоль с деталями: где упало, что ввели.

Оптимизация рендеринга. SFML немного подтормаживает при частых кликах — можно добавить дебаунсинг на события или перерисовывать только изменённые элементы через dirty rectangles.

Ещё я подумал про локализацию — например, заменить точку на запятую для регионов, где так принято, но это уже избыточно для такого проекта. В целом, SFML 2.6.1 дала мне ровно то, что я хотел: быстрый старт и минимум головной боли.

Рекомендации новичкам и не только

Если вы только начинаете, вот что я бы посоветовал:

  1. Не бойтесь библиотек вроде SFML — это не так страшно, как кажется. Она проще, чем Qt, и учит основам работы с графикой.

  2. Осваивайте современный C++ — умные указатели вроде unique_ptr и контейнеры вроде vector спасут вас от кучи багов с памятью.

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

  4. Делайте визуальную обратную связь — даже простая смена цвета кнопки делает UI живым и дружелюбным.

  5. Экспериментируйте с версиями — SFML 2.6.1 стабильна, но если вам нужны новые фичи, следите за веткой разработки на GitHub, уже доступна версия 3.1 .

Для проффи совет другой: возьмите такую простую задачу и доведите её до идеала. Добавьте тесты, профилирование, конфиги, обработку граничных случаев. Это хороший способ не закиснуть на рабочих рутинах и вспомнить, почему мы вообще любим кодить. Я, например, после этого проекта задумался, как бы переписать парсер на концепты C++20 или прикрутить многопоточность для рендеринга — просто ради интереса. Спасибо за внимание и всем хорошего времени суток.

Творите и любите своё творение, будьте добры один к другому!!!

Телеграмм канал - Программирование игр С++

Теги:
Хабы:
Всего голосов 9: ↑7 и ↓2+6
Комментарии18

Публикации

Работа

QT разработчик
7 вакансий
Программист C++
98 вакансий

Ближайшие события