Aplikacje desktopowe · Windows Forms

Kalkulator 4P: aplikacja Windows Forms z kolejnością działań

Zbudujesz kalkulator, który nie liczy „od lewej do prawej”, tylko rozumie kolejność działań, nawiasy, potęgi i pierwiastki. Przy okazji ćwiczysz projektowanie formularza, nazwy kontrolek, wspólną obsługę zdarzeń, walidację i wyjątki, czyli to, co potrzebne w zadaniach INF.04.

  • około 90 minut
  • Windows Forms · C# · .NET
  • INF.04.5

Po tej lekcji: zaprojektować formularz Windows Forms, nadać kontrolkom opisowe nazwy, podpiąć jedną metodę pod wiele przycisków i napisać parser wyrażeń z obsługą błędów.

Zacznijmy od sklepiku

4*12+5 powinno dać 53

W szkolnym sklepiku liczysz koszt czterech biletów po 12 zł i opłaty 5 zł. Wyrażenie 4*12+5 powinno zwrócić 53. Gdyby program liczył po kolei od lewej, dostałby (4*12)+5 tylko przez przypadek, a przy 5+4*12 pomyliłby się.

Efekt końcowy: działający kalkulator z historią działania, pamięcią MS/MR/MC i komunikatami dla błędnych wyrażeń.

Laboratorium · ćwiczenie w przeglądarce, do którego odsyłają rozgrzewki na końcu lekcji

1 · Zobacz, do czego dążysz

Kalkulator i zestaw testów z lekcji

Policz 2+2*2 poprawnym parserem, a potem przełącz na naiwny „od lewej do prawej” i porównaj. Uruchom też testy z lekcji w obu trybach. Spróbuj 7/0, (2+3 oraz pamięci: oblicz 12,5*4, kliknij MS, C, MR.

Ta interakcja wymaga włączonego JavaScriptu.

Poprawny wynik 2+2*2 to 6. Naiwny parser daje 8, bo najpierw dodaje. Cała różnica to kolejność poziomów w parserze.

Efekt końcowy

Tak ma wyglądać projekt

Formularz kalkulatora w projektancie Visual Studio i uruchomiona aplikacja
Po lewej projektant formularza, po prawej uruchomiony Kalkulator 4P. Porównuj układ kontrolek, ale buduj aplikację krok po kroku.

2 · Zacznij od projektu

Utwórz projekt Windows Forms

Projekt wykonujemy w Visual Studio jako Windows Forms App w C# (nowy szablon .NET, nie stary „.NET Framework”). Zaznaczaj kroki w trakcie.

3 · Krok 1

Właściwości formularza

WłaściwośćWartośćPo co?
(Name)oknoKalkulatoraOpisowa nazwa formularza w kodzie.
TextKalkulator 4PTytuł na belce okna.
ClientSize380; 560Miejsce na ekran i przyciski.
FormBorderStyleFixedSingleStały układ kontrolek, bez zmiany rozmiaru.
MaximizeBoxFalseWyłączenie maksymalizacji.
StartPositionCenterScreenStart na środku ekranu.
Kliknij puste tło formularza, naciśnij F4 i ustaw właściwości. `(Name)` to nazwa używana w kodzie, a `Text` to napis widoczny dla użytkownika.

4 · Krok 2

Wyświetlacz i etykieta historii

Kontrolka`(Name)`Najważniejsze właściwości
LabeletykietaHistoriaText: puste, TextAlign: MiddleRight, AutoSize: False
TextBoxpoleEkranText: 0, ReadOnly: True, TextAlign: Right, Font: Segoe UI 20 Bold
**Punkt kontrolny:** naciśnij F5. Okno ma pojawić się na środku, a w polu wyniku ma być widoczne `0`.

5 · Krok 3

Przyciski i ich nazwy

Napis`(Name)`Napis`(Name)`
MCprzyciskMCMRprzyciskMR
MSprzyciskMSCprzyciskWyczysc
⌫przyciskCofnij±przyciskZmienZnak
( i )przyciskNawiasOtworz, przyciskNawiasZamknij^ i √przyciskPotega, przyciskPierwiastek
1/xprzyciskUlamek+ − * /przyciskDodaj, przyciskOdejmij, przyciskPomnoz, przyciskPodziel
0–9przycisk0 … przycisk9= i przecinekprzyciskRownaSie, przyciskPrzecinek
Dodaj `TableLayoutPanel` o nazwie `siatkaPrzyciskow` (6 wierszy, 5 kolumn). Dla każdego przycisku ustaw `Dock = Fill`, aby były równe bez ręcznego przesuwania.

6 · Krok 4

Jedna metoda dla wielu przycisków

Nie twórz osobnej metody dla każdej cyfry. Zaznacz z klawiszem Ctrl przyciski 0–9, +, −, *, /, ^, ( i ). W oknie właściwości kliknij ikonę błyskawicy, w zdarzeniu Click wpisz PrzyciskZnak_Click i naciśnij Enter.

PrzyciskZnak_Clickwspólna metoda

Obsługuje cyfry i operatory. Kliknięty przycisk rozpoznaje przez parametr sender i jego Text.

Na przykład Button przycisk = (Button)sender;

Metody pojedynczekażdy po jednym

PrzyciskPrzecinek_Click, PrzyciskPierwiastek_Click, PrzyciskUlamek_Click, PrzyciskZmienZnak_Click, PrzyciskCofnij_Click, PrzyciskWyczysc_Click.

Na przykład każda ma inną logikę

PamięćMS, MR, MC

PrzyciskMS_Click, PrzyciskMR_Click, PrzyciskMC_Click zapisują, wczytują i czyszczą wartość pamięci.

Na przykład MR zmienia napis na MR*, gdy pamięć jest zajęta

PrzyciskRownaSie_Clickobliczenie

Wywołuje parser, pokazuje wynik albo komunikat o błędzie.

Na przykład try / catch

7 · Kolejność działań

Cztery poziomy, od najsłabszego do najsilniejszego

Metoda ObliczWyrazenie nie wykonuje tekstu „na ślepo”. Czyta wyrażenie poziomami. Każdy poziom wywołuje następny, silniejszy, dopóki nie dojdzie do liczby.

1. Dodawanie i odejmowanienajsłabszy

Zbiera składniki. Każdy składnik pobiera z poziomu mnożenia.

Na przykład 2 + 2*2 → 2 oraz 2*2

2. Mnożenie i dzieleniesilniejszy

Zbiera czynniki z poziomu potęgi. Tu sprawdzamy dzielenie przez zero.

Na przykład 2*2 → 4

3. Potęgaod prawej

2^3^2 to 2^(3^2), czyli 512. Wykładnik wywołuje ten sam poziom.

Na przykład 2^3^2 = 512

4. Czynniknajsilniejszy

Liczba, liczba ze znakiem, nawias albo pierwiastek. Nawias zaczyna cały proces od nowa.

Na przykład (2+3), √(9), -4

8 · Cały kod

Form1.cs

Nie wklejaj całości naraz i nie naciskaj od razu F5. Dodawaj kod etapami: pola klasy, wprowadzanie znaków, czyszczenie, pamięć, obliczanie, parser. Po każdym etapie zbuduj projekt (Build → Build Solution). Nie edytuj ręcznie Form1.Designer.cs.

Form1.cscsharp
using System;
using System.Globalization;
using System.Windows.Forms;

namespace Kalkulator4P
{
    public partial class Form1 : Form
    {
        private double pamiec = 0;
        private bool czyNoweDzialanie = false;

        public Form1()
        {
            InitializeComponent();
        }

        // Pobranie znaku z klikniętego przycisku i dopisanie go do wyrażenia
        private void PrzyciskZnak_Click(object sender, EventArgs e)
        {
            Button przycisk = (Button)sender;
            string znak = przycisk.Text;

            if (czyNoweDzialanie)
            {
                if ("+-*/^".Contains(znak))
                {
                    etykietaHistoria.Text = poleEkran.Text + " " + znak;
                    poleEkran.Text += znak;
                    czyNoweDzialanie = false;
                    return;
                }

                poleEkran.Text = "0";
                etykietaHistoria.Text = "";
                czyNoweDzialanie = false;
            }

            if (poleEkran.Text == "0" && !"+-*/^)".Contains(znak))
                poleEkran.Text = znak;
            else
                poleEkran.Text += znak;
        }

        private void PrzyciskPrzecinek_Click(object sender, EventArgs e)
        {
            if (czyNoweDzialanie)
            {
                poleEkran.Text = "0,";
                etykietaHistoria.Text = "";
                czyNoweDzialanie = false;
                return;
            }

            int indeks = poleEkran.Text.Length - 1;
            while (indeks >= 0 && (char.IsDigit(poleEkran.Text[indeks]) || poleEkran.Text[indeks] == ','))
                indeks--;

            string aktualnaLiczba = poleEkran.Text.Substring(indeks + 1);
            if (!aktualnaLiczba.Contains(","))
                poleEkran.Text += aktualnaLiczba.Length == 0 ? "0," : ",";
        }

        private void PrzyciskPierwiastek_Click(object sender, EventArgs e)
        {
            if (czyNoweDzialanie || poleEkran.Text == "0")
            {
                poleEkran.Text = "√(";
                etykietaHistoria.Text = "";
                czyNoweDzialanie = false;
            }
            else
            {
                poleEkran.Text += "√(";
            }
        }

        private void PrzyciskUlamek_Click(object sender, EventArgs e)
        {
            if (czyNoweDzialanie || poleEkran.Text == "0")
            {
                poleEkran.Text = "1/(";
                etykietaHistoria.Text = "";
                czyNoweDzialanie = false;
            }
            else
            {
                poleEkran.Text += "1/(";
            }
        }

        private void PrzyciskZmienZnak_Click(object sender, EventArgs e)
        {
            if (poleEkran.Text == "0") return;

            poleEkran.Text = poleEkran.Text.StartsWith("-")
                ? poleEkran.Text.Substring(1)
                : "-" + poleEkran.Text;
        }

        // Edycja wyrażenia
        private void PrzyciskCofnij_Click(object sender, EventArgs e)
        {
            if (czyNoweDzialanie)
            {
                PrzyciskWyczysc_Click(sender, e);
                return;
            }

            poleEkran.Text = poleEkran.Text.Length > 1
                ? poleEkran.Text.Substring(0, poleEkran.Text.Length - 1)
                : "0";
        }

        private void PrzyciskWyczysc_Click(object sender, EventArgs e)
        {
            poleEkran.Text = "0";
            etykietaHistoria.Text = "";
            czyNoweDzialanie = false;
        }

        // Pamięć kalkulatora
        private void PrzyciskMS_Click(object sender, EventArgs e)
        {
            try
            {
                pamiec = ObliczWyrazenie(poleEkran.Text);
                przyciskMR.Text = "MR*";
                MessageBox.Show($"Zapisano do pamięci: {pamiec}", "Pamięć");
            }
            catch (Exception blad)
            {
                MessageBox.Show("Nie zapisano wartości: " + blad.Message, "Błąd");
            }
        }

        private void PrzyciskMR_Click(object sender, EventArgs e)
        {
            string liczba = pamiec.ToString(CultureInfo.CurrentCulture);
            poleEkran.Text = czyNoweDzialanie || poleEkran.Text == "0"
                ? liczba
                : poleEkran.Text + liczba;
            etykietaHistoria.Text = "";
            czyNoweDzialanie = false;
        }

        private void PrzyciskMC_Click(object sender, EventArgs e)
        {
            pamiec = 0;
            przyciskMR.Text = "MR";
            MessageBox.Show("Pamięć została wyczyszczona.", "Pamięć");
        }

        // Obliczenie wyniku, obsługa błędów i prezentacja rezultatu
        private void PrzyciskRownaSie_Click(object sender, EventArgs e)
        {
            string wyrazenie = poleEkran.Text;

            try
            {
                double wynik = ObliczWyrazenie(wyrazenie);
                etykietaHistoria.Text = wyrazenie + " =";
                poleEkran.Text = wynik.ToString(CultureInfo.CurrentCulture);
                czyNoweDzialanie = true;
            }
            catch (DivideByZeroException)
            {
                MessageBox.Show("Nie można dzielić przez zero!", "Błąd matematyczny");
            }
            catch (Exception blad)
            {
                MessageBox.Show("Sprawdź wyrażenie: " + blad.Message, "Błąd składni");
            }
        }

        // Parser zachowujący kolejność działań
        private double ObliczWyrazenie(string wyrazenie)
        {
            wyrazenie = wyrazenie.Replace(" ", "").Replace(",", ".");
            int indeks = 0;

            double ParsujDodawanieOdejmowanie()
            {
                double wartosc = ParsujMnozenieDzielenie();
                while (indeks < wyrazenie.Length && (wyrazenie[indeks] == '+' || wyrazenie[indeks] == '-'))
                {
                    char dzialanie = wyrazenie[indeks++];
                    double kolejnaWartosc = ParsujMnozenieDzielenie();
                    wartosc = dzialanie == '+' ? wartosc + kolejnaWartosc : wartosc - kolejnaWartosc;
                }
                return wartosc;
            }

            double ParsujMnozenieDzielenie()
            {
                double wartosc = ParsujPotege();
                while (indeks < wyrazenie.Length && (wyrazenie[indeks] == '*' || wyrazenie[indeks] == '/'))
                {
                    char dzialanie = wyrazenie[indeks++];
                    double kolejnaWartosc = ParsujPotege();
                    if (dzialanie == '/' && kolejnaWartosc == 0)
                        throw new DivideByZeroException();

                    wartosc = dzialanie == '*' ? wartosc * kolejnaWartosc : wartosc / kolejnaWartosc;
                }
                return wartosc;
            }

            double ParsujPotege()
            {
                double podstawa = ParsujCzynnik();
                if (indeks < wyrazenie.Length && wyrazenie[indeks] == '^')
                {
                    indeks++;
                    double wykladnik = ParsujPotege();
                    return Math.Pow(podstawa, wykladnik);
                }
                return podstawa;
            }

            double ParsujCzynnik()
            {
                if (indeks >= wyrazenie.Length)
                    throw new FormatException("Brakuje liczby lub zamknięcia działania.");

                if (wyrazenie[indeks] == '-' || wyrazenie[indeks] == '+')
                {
                    char znak = wyrazenie[indeks++];
                    double wartosc = ParsujCzynnik();
                    return znak == '-' ? -wartosc : wartosc;
                }

                if (wyrazenie[indeks] == '(')
                {
                    indeks++;
                    double wewnatrz = ParsujDodawanieOdejmowanie();
                    if (indeks >= wyrazenie.Length || wyrazenie[indeks] != ')')
                        throw new FormatException("Brakuje nawiasu zamykającego ).");
                    indeks++;
                    return wewnatrz;
                }

                if (wyrazenie[indeks] == '√')
                {
                    indeks++;
                    double liczba = ParsujCzynnik();
                    if (liczba < 0)
                        throw new ArithmeticException("Nie obliczamy pierwiastka z liczby ujemnej.");
                    return Math.Sqrt(liczba);
                }

                int poczatek = indeks;
                while (indeks < wyrazenie.Length &&
                       (char.IsDigit(wyrazenie[indeks]) || wyrazenie[indeks] == '.'))
                    indeks++;

                if (poczatek == indeks)
                    throw new FormatException($"Niepoprawny znak: {wyrazenie[indeks]}.");

                string liczbaTekst = wyrazenie.Substring(poczatek, indeks - poczatek);
                return double.Parse(liczbaTekst, CultureInfo.InvariantCulture);
            }

            double wynik = ParsujDodawanieOdejmowanie();
            if (indeks != wyrazenie.Length)
                throw new FormatException($"Nieoczekiwany znak: {wyrazenie[indeks]}.");

            return wynik;
        }
    }
}
  1. private double pamiec, czyNoweDzialanie Dwa pola klasy: wartość pamięci i znacznik, że ekran pokazuje wynik poprzedniego działania.
  2. (Button)sender i przycisk.Text Jedna metoda dla wielu przycisków. Znak pochodzi z napisu na klikniętym przycisku.
  3. czyNoweDzialanie Po = cyfra zaczyna nowe wyrażenie, ale operator kontynuuje liczenie na wyniku.
  4. PrzyciskPrzecinek_Click Przeszukuje wyrażenie od końca, aby dopuścić tylko jeden przecinek w bieżącej liczbie.
  5. MessageBox.Show(...) w MS i MC Komunikaty o stanie pamięci.
  6. catch (DivideByZeroException) Osobny komunikat dla dzielenia przez zero. Pozostałe błędy trafiają do catch (Exception).
  7. ParsujDodawanieOdejmowanie ... ParsujCzynnik Funkcje lokalne parsera, po jednej na poziom. Wspólna zmienna indeks wskazuje bieżącą pozycję w tekście.
  8. throw new FormatException(...) Zgłasza błąd składni, np. brakujący nawias. Łapie go PrzyciskRownaSie_Click.
  9. double.Parse(..., CultureInfo.InvariantCulture) Przecinek zamieniliśmy na kropkę, więc liczby czytamy niezależnie od ustawień regionalnych.

Do zadań

Procent, historia wyników i skróty klawiaturowe

Trzy zadania wymagają elementów, których nie ma w przykładzie: ListBox, zdarzenia DoubleClick i klawiatury formularza. Poniżej gotowe fragmenty do wklejenia i dopasowania.

Form1.cscsharp
// Zadanie 1: przycisk % (nazwa przyciskProcent)
private void przyciskProcent_Click(object sender, EventArgs e)
{
    try
    {
        double liczba = double.Parse(poleEkran.Text, CultureInfo.CurrentCulture);
        poleEkran.Text = (liczba / 100).ToString(CultureInfo.CurrentCulture);
    }
    catch (FormatException)
    {
        poleEkran.Text = "Błąd";
    }
}

// Zadanie 2: historia w ListBox o nazwie listaHistorii
private void DodajDoHistorii(string wyrazenie, string wynik)
{
    listaHistorii.Items.Add(wyrazenie + " = " + wynik);
    if (listaHistorii.Items.Count > 5)
        listaHistorii.Items.RemoveAt(0);               // zostaje pięć ostatnich
}

private void listaHistorii_DoubleClick(object sender, EventArgs e)
{
    if (listaHistorii.SelectedItem is string wpis)
        poleEkran.Text = wpis.Split('=')[^1].Trim();   // część po znaku =
}

// Zadanie 3: formularz.KeyPreview = True (okno Właściwości), zdarzenie KeyDown formularza
private void Form1_KeyDown(object sender, KeyEventArgs e)
{
    if (e.KeyCode == Keys.Enter)  { przyciskRownaSie.PerformClick(); e.SuppressKeyPress = true; }
    else if (e.KeyCode == Keys.Escape) przyciskWyczysc.PerformClick();
    else if (e.KeyCode == Keys.Back)   przyciskCofnij.PerformClick();
}
  1. KeyPreview = True Bez tego pola formularza nie dostaje klawiszy, bo przejmuje je kontrolka z fokusem (np. przycisk).
  2. PerformClick() Wykonuje kliknięcie przycisku z kodu, więc klawisz i przycisk używają tej samej logiki.
  3. Cyfry i operatory Dla znaków użyj zdarzenia KeyPress (e.KeyChar) i wydziel z PrzyciskZnak_Click wspólną metodę DopiszZnak(string znak).

9 · Krok 5

Uruchom testy i nie zaliczaj tylko dlatego, że okno się otwiera

WpiszOczekiwany rezultatCo sprawdzasz
2+2*26Priorytet mnożenia
(2+2)*28Nawiasy
2^3^2512Potęgowanie od prawej
√(9)+14Pierwiastek
(1/2)+(1/4)0,75Ułamki i dzielenie
7/0komunikat o dzieleniu przez zeroObsługa wyjątku
(2+3komunikat o brakującym nawiasieWalidacja składni
Na końcu oblicz `12,5*4`, kliknij MS, C, MR. Na ekran powinno wrócić 50, a napis MR powinien wcześniej zmienić się na `MR*`.

10 · Czego unikać

Cztery błędy, które zobaczysz na pewno

Kontrolka nie istnieje w bieżącym kontekście
Tak nie
PoleEkran.Text = "0";   // w projektancie: poleEkran

Błąd CS0103. PoleEkran i poleEkran to różne nazwy.

Tak
poleEkran.Text = "0";

Wielkość liter ma znaczenie. Porównaj nazwę w kodzie z właściwością (Name).

Przycisk nic nie robi
Tak nie
Zdarzenie Click przycisku jest puste w oknie właściwości

Kliknięcie nie uruchamia żadnej metody.

Tak
Zaznacz przycisk → F4 → ikona błyskawicy → wpisz nazwę metody w polu Click

Nazwa metody w projektancie musi być identyczna jak w kodzie.

Każda cyfra ma osobną metodę
Tak nie
private void Przycisk1_Click(...) { poleEkran.Text += "1"; }
private void Przycisk2_Click(...) { poleEkran.Text += "2"; }
// ... osiem kolejnych metod

Dużo powtórzonego kodu, każda zmiana wymaga dziesięciu poprawek.

Tak
private void PrzyciskZnak_Click(object sender, EventArgs e)
{
    Button przycisk = (Button)sender;
    string znak = przycisk.Text;
    // ...
}

Jeden sender zastępuje dziesięć metod.

`2+2*2` daje 8
Tak nie
// dodawanie wywołuje ParsujPotege zamiast ParsujMnozenieDzielenie

Działania są wykonywane w złej kolejności.

Tak
ParsujDodawanieOdejmowanie → ParsujMnozenieDzielenie → ParsujPotege → ParsujCzynnik

Każdy poziom pobiera operandy z poziomu silniejszego.

11 · Sprawdź się

Nazwy, zdarzenia i parser

Cztery krótkie sytuacje z projektu kalkulatora.

Ta interakcja wymaga włączonego JavaScriptu.

Podsumowanie

Co warto zapamiętać

  1. Kontrolkom nadajesz opisowe nazwy w (Name), a napis widoczny dla użytkownika to Text.
  2. Jedną metodę podpinasz pod wiele przycisków przez zdarzenie Click i odczyt sender.
  3. Parser zachowuje kolejność działań dzięki poziomom: + -, potem * /, potem ^, na końcu liczba lub nawias.
  4. Błędy użytkownika (dzielenie przez zero, brak nawiasu) obsługujesz przez try / catch, a nie zawieszeniem programu.
  5. Aplikację sprawdzaj zestawem testów, a nie samym faktem, że okno się otworzyło.

Dalej na swoim koncie

Rozgrzewki, quiz i zadania są w biurku ucznia.

Teoria jest otwarta dla wszystkich. Żeby rozwiązywać zadania, zapisywać szkice i widzieć swój postęp, zaloguj się.

Zaloguj się →
Źródła i dalsza nauka