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.
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
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) | oknoKalkulatora | Opisowa nazwa formularza w kodzie. |
Text | Kalkulator 4P | Tytuł na belce okna. |
ClientSize | 380; 560 | Miejsce na ekran i przyciski. |
FormBorderStyle | FixedSingle | Stały układ kontrolek, bez zmiany rozmiaru. |
MaximizeBox | False | Wyłączenie maksymalizacji. |
StartPosition | CenterScreen | Start na środku ekranu. |
4 · Krok 2
Wyświetlacz i etykieta historii
| Kontrolka | `(Name)` | Najważniejsze właściwości |
|---|---|---|
Label | etykietaHistoria | Text: puste, TextAlign: MiddleRight, AutoSize: False |
TextBox | poleEkran | Text: 0, ReadOnly: True, TextAlign: Right, Font: Segoe UI 20 Bold |
5 · Krok 3
Przyciski i ich nazwy
| Napis | `(Name)` | Napis | `(Name)` |
|---|---|---|---|
| MC | przyciskMC | MR | przyciskMR |
| MS | przyciskMS | C | przyciskWyczysc |
| ⌫ | przyciskCofnij | ± | przyciskZmienZnak |
| ( i ) | przyciskNawiasOtworz, przyciskNawiasZamknij | ^ i √ | przyciskPotega, przyciskPierwiastek |
| 1/x | przyciskUlamek | + − * / | przyciskDodaj, przyciskOdejmij, przyciskPomnoz, przyciskPodziel |
| 0–9 | przycisk0 … przycisk9 | = i przecinek | przyciskRownaSie, przyciskPrzecinek |
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.
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;
}
}
}
private double pamiec,czyNoweDzialanieDwa pola klasy: wartość pamięci i znacznik, że ekran pokazuje wynik poprzedniego działania.(Button)senderiprzycisk.TextJedna metoda dla wielu przycisków. Znak pochodzi z napisu na klikniętym przycisku.czyNoweDzialaniePo=cyfra zaczyna nowe wyrażenie, ale operator kontynuuje liczenie na wyniku.PrzyciskPrzecinek_ClickPrzeszukuje wyrażenie od końca, aby dopuścić tylko jeden przecinek w bieżącej liczbie.MessageBox.Show(...)w MS i MC Komunikaty o stanie pamięci.catch (DivideByZeroException)Osobny komunikat dla dzielenia przez zero. Pozostałe błędy trafiają docatch (Exception).ParsujDodawanieOdejmowanie...ParsujCzynnikFunkcje lokalne parsera, po jednej na poziom. Wspólna zmiennaindekswskazuje bieżącą pozycję w tekście.throw new FormatException(...)Zgłasza błąd składni, np. brakujący nawias. Łapie goPrzyciskRownaSie_Click.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.
// 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();
}
KeyPreview = TrueBez tego pola formularza nie dostaje klawiszy, bo przejmuje je kontrolka z fokusem (np. przycisk).PerformClick()Wykonuje kliknięcie przycisku z kodu, więc klawisz i przycisk używają tej samej logiki.- Cyfry i operatory Dla znaków użyj zdarzenia
KeyPress(e.KeyChar) i wydziel zPrzyciskZnak_Clickwspólną metodęDopiszZnak(string znak).
9 · Krok 5
Uruchom testy i nie zaliczaj tylko dlatego, że okno się otwiera
| Wpisz | Oczekiwany rezultat | Co sprawdzasz |
|---|---|---|
2+2*2 | 6 | Priorytet mnożenia |
(2+2)*2 | 8 | Nawiasy |
2^3^2 | 512 | Potęgowanie od prawej |
√(9)+1 | 4 | Pierwiastek |
(1/2)+(1/4) | 0,75 | Ułamki i dzielenie |
7/0 | komunikat o dzieleniu przez zero | Obsługa wyjątku |
(2+3 | komunikat o brakującym nawiasie | Walidacja składni |
10 · Czego unikać
Cztery błędy, które zobaczysz na pewno
Kontrolka nie istnieje w bieżącym kontekście
PoleEkran.Text = "0"; // w projektancie: poleEkranBłąd CS0103. PoleEkran i poleEkran to różne nazwy.
poleEkran.Text = "0";Wielkość liter ma znaczenie. Porównaj nazwę w kodzie z właściwością (Name).
Przycisk nic nie robi
Zdarzenie Click przycisku jest puste w oknie właściwościKliknięcie nie uruchamia żadnej metody.
Zaznacz przycisk → F4 → ikona błyskawicy → wpisz nazwę metody w polu ClickNazwa metody w projektancie musi być identyczna jak w kodzie.
Każda cyfra ma osobną metodę
private void Przycisk1_Click(...) { poleEkran.Text += "1"; }
private void Przycisk2_Click(...) { poleEkran.Text += "2"; }
// ... osiem kolejnych metodDużo powtórzonego kodu, każda zmiana wymaga dziesięciu poprawek.
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
// dodawanie wywołuje ParsujPotege zamiast ParsujMnozenieDzielenieDziałania są wykonywane w złej kolejności.
ParsujDodawanieOdejmowanie → ParsujMnozenieDzielenie → ParsujPotege → ParsujCzynnikKażdy poziom pobiera operandy z poziomu silniejszego.
11 · Sprawdź się
Nazwy, zdarzenia i parser
Cztery krótkie sytuacje z projektu kalkulatora.
Podsumowanie
Co warto zapamiętać
- Kontrolkom nadajesz opisowe nazwy w
(Name), a napis widoczny dla użytkownika toText. - Jedną metodę podpinasz pod wiele przycisków przez zdarzenie
Clicki odczytsender. - Parser zachowuje kolejność działań dzięki poziomom:
+ -, potem* /, potem^, na końcu liczba lub nawias. - Błędy użytkownika (dzielenie przez zero, brak nawiasu) obsługujesz przez
try/catch, a nie zawieszeniem programu. - 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ę →