Pierwszy program w Ruby

0

Przesiadłem się z Pythona na Ruby. Powodem przesiadki było to że do Pythona muszę się zmuszać a Ruby jakoś mi bardziej przypasował po prostu.

Proszę o code review kodu

# frozen_string_literal: true

def read_number(prompt = 'Enter the number: ')
  loop do
    print prompt
    user_input = Float(gets.chomp)

    return Float(user_input)
  rescue ArgumentError
    puts 'Error: not a number.'
  end
end

loop do
  puts '*** Main menu ***'
  puts '1) Add'
  puts '2) Subtract'
  puts '3) Multiply'
  puts '4) Divide'
  puts '5) Quit'
  user_choice = read_number 'Your choice: '

  exit if user_choice == 5

  first = read_number
  last = read_number

  case user_choice
  when 1
    puts "The result is #{first + last}"
  when 2
    puts "The result is #{first - last}"
  when 3
    puts "The result is #{first * last}"
  when 4
    begin
      raise ZeroDivisionError if last.zero?

      puts "The result is #{first / last}"
    rescue ZeroDivisionError
      puts 'Error: cannot divide by zero.'
    end
  else
    puts 'Error: invalid choice.'
  end
end

3

Ok . Ale to jest tak małe, że naprawdę trzeba się starać, żeby coś zepsuć.

3
S4t napisał(a):

Ok . Ale to jest tak małe, że naprawdę trzeba się starać, żeby coś zepsuć.

Potrzymaj mi piwo XD

Wydzialiłbym logikę interpretera/kalkulatora do osobnej funkcji np def calculate(operation, operand1, operand2). Wtedy można by do tego napisać testy jak człowiek. Bo teraz bardziej masz skrypt z pomieszanymi operacjami wyjścia-wejścia i logiką. a nie testowalny program.

No i to chyba jedyne do czego mogę się przyczepić nie znając Rubiega XD

Oczywiście z bardziej rozbudowanych wersjach by można mieć jakiegoś enuma (są w Rubim enumy?) albo chociaż jakieś stałe żeby nie mieć magicznych wartości 1,2,3,4 . Widać że już myślę spaczeniami z hobbystycznego pisania parserów/interpeterów XD

2
KamilAdam napisał(a):
S4t napisał(a):

Ok . Ale to jest tak małe, że naprawdę trzeba się starać, żeby coś zepsuć.

Potrzymaj mi piwo XD

Wydzialiłbym logikę interpretera/kalkulatora do osobnej funkcji np def calculate(operation, operand1, operand2). Wtedy można by do tego napisać testy jak człowiek. Bo teraz bardziej masz skrypt z pomieszanymi operacjami wyjścia-wejścia i logiką. a nie testowalny program.

Oj, jako fanatyk testowania chciałbym powiedzieć że jaknajbardziej testowalny.

I nawet powiedziałbym że lepiej testowany, niż z wydzieloną funkcją def calculate(operation, operand1, operand2), bo wtedy testy stałyby się ridid, i refaktor tej funkcji byłby trudniejszy. Dokładnie takie testy mam w programie /crlf. Testy korzystają z CLI do testów - więc cały program mógłby być przepisany, i jeśli tylko ma taki sam interfejs to testy przechodzą. Są też łatwe do zmiany, bo same testy nie wiedzą o CLI. Mam mały zbiór funkcji który woła to CLI, a testy korzystają z tych funkcji. Jeśli interfejs CLI wymagałby zmiany, to musiałbym zmienić tylko ten mały podzbiór funkcji, a testy nadal przechodzą tak jak przechodziły. Więc z punktu widzenia testów, nie wydzielałbym tej funkcji.

Ale! Muszę przyznać @KamilAdam rację że faktycznie lepiej byłoby wydzielić funkcję def calculate(operation, operand1, operand2) po to żeby odseparować interfejs od logiki - i to faktycznie trzeba zrobić. Tylko nie z powodu testów, tylko z uwagi na lepszy design.

KamilAdam napisał(a):

Oczywiście z bardziej rozbudowanych wersjach by można mieć jakiegoś enuma (są w Rubim enumy?) albo chociaż jakieś stałe żeby nie mieć magicznych wartości 1,2,3,4 . Widać że już myślę spaczeniami z hobbystycznego pisania parserów/interpeterów XD

Nie ma, ale są symbole, :one, to jest taki trochę runtime'owy const - dostaje longa przy pierwszym użyciu, więc lepiej się go porównuje niż stringi.

2
lester29 napisał(a):

Przesiadłem się z Pythona na Ruby. Powodem przesiadki było to że do Pythona muszę się zmuszać a Ruby jakoś mi bardziej przypasował po prostu.

Takich programów się już nie tworzy, takich które proszą użytkownika o input w stylu "podaj liczbę, enter, podałeś złą liczbę". Jeśli chcesz stworzyć taki tool, to dużo lepiej byłoby gdyby przyjmował argumenty przez argv, tak żeby dało się go uruchomić np tak:

ruby myprogram.rb --multiply 2 5

Ale żeby nie kończyć tak negatywnie, to ocenię ten program:

# frozen_string_literal: true

def read_number(prompt = 'Enter the number: ')    # lepiej nie używać domyślnych argumentów, dobrze jest je przekazać explicitly
  loop do
    print prompt
    user_input = Float(gets.chomp)                # ta zmienna `user_input` jest potrzebna?

    return Float(user_input)                      # Robisz Float() dwa razy?
  rescue ArgumentError
    puts 'Error: not a number.'
  end
end

loop do
  puts '*** Main menu ***'                        # printowanie tego menu dobrze byłoby wynieść do innej funkcji
  puts '1) Add'
  puts '2) Subtract'
  puts '3) Multiply'
  puts '4) Divide'
  puts '5) Quit'
  user_choice = read_number 'Your choice: '

  exit if user_choice == 5

  first = read_number
  last = read_number

  case user_choice
  when 1
    puts "The result is #{first + last}"          # powtarzasz "the result is" kilka razy, lepiej byłoby wynieść obliczanie
  when 2                                          # do innej funkcji, a wyświetlanie do innej
    puts "The result is #{first - last}"
  when 3
    puts "The result is #{first * last}"
  when 4
    begin
      raise ZeroDivisionError if last.zero?

      puts "The result is #{first / last}"
    rescue ZeroDivisionError
      puts 'Error: cannot divide by zero.'
    end
  else
    puts 'Error: invalid choice.'
  end
end
0
Riddle napisał(a):

Oj, jako fanatyk testowania chciałbym powiedzieć że jaknajbardziej testowalny.

I nawet powiedziałbym że lepiej testowany, niż z wydzieloną funkcją def calculate(operation, operand1, operand2), bo wtedy testy stałyby się ridid, i refaktor tej funkcji byłby trudniejszy. Dokładnie takie testy mam w programie /crlf. Testy korzystają z CLI do testów - więc cały program mógłby być przepisany, i jeśli tylko ma taki sam interfejs to testy przechodzą. Są też łatwe do zmiany, bo same testy nie wiedzą o CLI. Mam mały zbiór funkcji który woła to CLI, a testy korzystają z tych funkcji. Jeśli interfejs CLI wymagałby zmiany, to musiałbym zmienić tylko ten mały podzbiór funkcji, a testy nadal przechodzą tak jak przechodziły. Więc z punktu widzenia testów, nie wydzielałbym tej funkcji.

Jesli dobrze pamiętam twój projekt to tam masz czytanie z plików. Tu masz czytanie i pisanie na standardowe wejście/wyjście. Nie wiem jak w Ruby, ale w Javie uznaje się coś takiego za zły (nietestowalny) interfejs

Oczywiście zupełnie inne sytuacja jakby kalkulator przyjmował stringa i go parsował, albo przyjmował ścieżkę do pliku i go parsował

0
KamilAdam napisał(a):
Riddle napisał(a):

Oj, jako fanatyk testowania chciałbym powiedzieć że jaknajbardziej testowalny.

I nawet powiedziałbym że lepiej testowany, niż z wydzieloną funkcją def calculate(operation, operand1, operand2), bo wtedy testy stałyby się ridid, i refaktor tej funkcji byłby trudniejszy. Dokładnie takie testy mam w programie /crlf. Testy korzystają z CLI do testów - więc cały program mógłby być przepisany, i jeśli tylko ma taki sam interfejs to testy przechodzą. Są też łatwe do zmiany, bo same testy nie wiedzą o CLI. Mam mały zbiór funkcji który woła to CLI, a testy korzystają z tych funkcji. Jeśli interfejs CLI wymagałby zmiany, to musiałbym zmienić tylko ten mały podzbiór funkcji, a testy nadal przechodzą tak jak przechodziły. Więc z punktu widzenia testów, nie wydzielałbym tej funkcji.

Jesli dobrze pamiętam twój projekt to tam masz czytanie z plików. Tu masz czytanie i pisanie na standardowe wejście/wyjście.

Zarówno plików jak i wyjście standardowe. Aplikacja printuje wynik swojego działania na plikach, więc naturalnie muszę to przetestować.

KamilAdam napisał(a):

Nie wiem jak w Ruby, ale w Javie uznaje się coś takiego za zły (nietestowalny) interfejs

Nie ma znaczenia jaka technologia. Niedużym wysiłkiem mógłbym przepisać aplikację z Pythona na inny język a moje testy nadal by przechodziły. Testy jedynie wiedzą jaki jest input/output oraz operacje na plikach. Testy nie wołają żadnych metod z kodu źrodłowego.

Nie ma powodu żeby uważać że input/output jest nietestowalny - jedyny powód ku temu to to że nie ma gotowych narzędzi żeby odczytać wyjście standardowe. Ale dla mnie to nie problem, napisałem sobie dwa adaptery na stdin/stdout, i teraz mam około 1000 testów które moim zdaniem są bardzo dobre. Gdyby testy teraz zaczęły wołać do konkretnych funkcji, to moim zdaniem osłabiło by to siłę testów.

KamilAdam napisał(a):

Oczywiście zupełnie inne sytuacja jakby kalkulator przyjmował stringa i go parsował, albo przyjmował ścieżkę do pliku i go parsował

No właśnie dla mnie to nie ma żadnego znaczenia, bo oba z tych podejść, czyli "przyjmował stringa i go parsował" oraz "przyjmuje standard input/output" to jest szczegół implementacyjny. W obu przypadkach testy muszą to wyabstraktować, a testy nie będą wiedziały z czego korzystają, więc to nie ma znaczenia.

0
Riddle napisał(a):

Nie ma znaczenia jaka technologia. Niedużym wysiłkiem mógłbym przepisać aplikację z Pythona na inny język a moje testy nadal by przechodziły. Testy jedynie wiedzą jaki jest input/output oraz operacje na plikach. Testy nie wołają żadnych metod z kodu źrodłowego.

W sensie, wywołujesz egzeka czy tam intepreter który owrapowałeś? No tak, zapomniałem. Ale to raczej niestandardowe podejście XD

0
KamilAdam napisał(a):
Riddle napisał(a):

Nie ma znaczenia jaka technologia. Niedużym wysiłkiem mógłbym przepisać aplikację z Pythona na inny język a moje testy nadal by przechodziły. Testy jedynie wiedzą jaki jest input/output oraz operacje na plikach. Testy nie wołają żadnych metod z kodu źrodłowego.

W sensie, wywołujesz egzeka czy tam intepreter który owrapowałeś? No tak, zapomniałem. Ale to raczej niestandardowe podejście XD

To też jest szczegół implementacyjny. Można wywołać egzeka, ale to trwa trochę dłużej, więc ja wołam main()a, i podmieniam sys.stdin oraz sys.stdout. Jednak wszystko to się dzieje w jednej funkcji, którą wołają testy. Równie dobrze możnaby tam wywołać egzeka, wheel, egg, jara, dllke, wszystko jedno. Jak poprawnie odseparujesz testy od aplikacji, to dowolny interfejs jest akceptowalny, możesz je nawet podmieniać z runnerem, i odpalać testy np pytest --my-super-interface egzek lub pytest --my-super-interface main.

Weź pod uwagę że jak piszesz test, który dla przykładu sprawdza czy przekazanie --version sprawi że wyprintuje się wersja 1.0.12, to wszystko jedno czy to będzie main() czy egzek, czy jakakolwiek inna forma. Test nie powinien o tym wiedzieć.

Dla przykładu

def run(argv):  # option 2
  from main import main
  ob = capture_stdout()
  main(argv)
  return str(ob)
  

def run(argv):  # option 1
  from subprocess import open
  return open("python", "main.py", ...argv).stdout.body()

def test():
  output = run('--version');
  assert output == "1.0.23"

Nie ważne którą run() odpalisz, test sprawdza to samo i przechodzi tak samo. Można to też zamienić w polimorfizm, żeby decydować o sposobie uruchamiania np parameterem.

Dzięki temu mogę do woli refaktorować każdy jeden aspekt programu, mogę go nawet przepisać w całości, i jeśli tylko przyjmuje te same argumenty i działa tak samo, to wszystkie testy przejdą.

1 użytkowników online, w tym zalogowanych: 0, gości: 1