Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Sorbet - Is it really that tasty?

Sorbet - Is it really that tasty?

Sorbet is a gradual type checker developed by Stripe, that offers similar features as TypeScript. It can be a tremendous help when starting a new Ruby project but it also poses challenges when you have to interface with other Ruby libraries and yes, it will remove the ducks from your typing.

In this talk we'll walk through the common scenarios of Sorbet, point out the pitfalls and navigate the tricky parts sucessfully. We are also going to scratch the surface of the advanced features that Sorbet offers without drifting off too deep into type nerdery. Finally there is also going to be a brief tutorial on how to introduce Sorbet types into existing code bases that are untyped.

Leif Gensert

May 04, 2023
Tweet

More Decks by Leif Gensert

Other Decks in Technology

Transcript

  1. “If it walks like a duck and it quacks like

    a duck, then it must be a duck“ class Duck def quack "Duck is quacking" end def waddle "Duck is waddling" end end class Goose def quack "Goose is quacking" end def waddle "Goose is waddling" end end class Herder def move_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.waddle } end def talk_to_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.quack } end end flock = [ Duck.new, Duck.new, Goose.new, Duck.new ] Herder.new.move_flock(flock) puts "====" Herder.new.talk_to_flock(flock) class Dog def bark puts "Dog is barking" end def walk puts "Dog is walking" end end flock = [ Duck.new, Duck.new, Dog.new, Duck.new ] Herder.new.move_flock(flock) puts "====" Herder.new.talk_to_flock(flock) `block in move_flock': undefined method `waddle' for #<Dog:0x000000010d452b68> (NoMethodError) anatidaes.each { |anatidae| anatidae.waddle } Dynamic Typing
  2. Examples in Ruby Stdlib class FilePrinter def initialize(file) @file =

    file end def print_with_emojis @file.each_line do |line| puts "#{line} \u{1f525}" end end end gemfile = File.new("Gemfile.lock") FilePrinter.new(gemfile).print_with_emojis GEM 🔥 remote: https://rubygems.org/ 🔥 specs: 🔥 🔥 PLATFORMS 🔥 x86_64-darwin-22 🔥 🔥 DEPENDENCIES 🔥 🔥 BUNDLED WITH 🔥 2.4.12 🔥 first_line 🔥 second_line 🔥 third_line 🔥 require "stringio" content = "first_line\nsecond_line\nthird_line" fake_file = StringIO.new(content) FilePrinter.new(fake_file).print_with_emojis
  3. “I don’t care what you look like or what you

    sound like In order to be a duck, you must have the label ‘duck’“ Static Typing public class Duck { public String quack() { return "Duck is quacking"; } public String waddle() { return "Duck is waddling"; } } public class Goose { public String quack() { return "Goose is quacking"; } public String waddle() { return "Goose is waddling"; } } public class Herder { public static void moveFlock(Duck[] ducks) { for (Duck duck : ducks) { System.out.println(duck.waddle()); } } public static void talkToFlock(Duck[] ducks) { for (Duck duck : ducks) { System.out.println(duck.quack()); } } public static void main(String[] args) { Duck[] ducks = new Duck[] { new Duck(), new Duck(), new Goose() }; Herder herder = new Herder(); herder.moveFlock(ducks); } } javac *.java Herder.java:16: error: incompatible types: Goose cannot be converted to Duck Duck[] ducks = new Duck[] { new Duck(), new Duck(), new Goose() }; ^ 1 error
  4. Dynamic Typing Static Typing Quicker Turnaround of Code Type Errors

    Appear at Runtime Code (Potentially) Harder to Understand Slower Turnaround Because of Necessary Checks Type Errors Are Caught Before Runtime Automatic Documentation of Types Pick and Choose
  5. gem "sorbet-static" gem ”sorbet-runtime” # frozen_string_literal: true source "https://rubygems.org" gem

    "sorbet-static-and-runtime" gem "tapioca" Gemfile CLI Type Checker (srb tc) Syntax for type annotations + data structures # typed: strict require "sorbet-runtime" class Duck extend T::Sig sig { returns(String) } def quack "Duck is quacking" end sig { returns(String) } def waddle "Duck is waddling" end end # typed: strict require “sorbet-runtime" class Goose extend T::Sig sig { returns(String) } def quack "Goose is quacking" end sig { returns(String) } def waddle "Goose is waddling" end end # typed: strict class Herder extend T::Sig sig { params(duck: Duck).void } def talk(duck) puts duck.quack end end # typed: strict Herder.new.talk(Goose.new) bundle exec srb (typecheck|tc) Expected Duck but found Goose for argument duck https://srb.help/7002 12 |Herder.new.talk(Goose.new) ^^^^^^^^^ Expected Duck for argument duck of method Herder#talk: herder.rb:6: 6 | sig { params(duck: Duck).void } ^^^^ Got Goose originating from: run.rb:12: 12 |Herder.new.talk(Goose.new) ^^^^^^^^^ lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:296:in `call_validation_error_handler_default’: Parameter 'duck': Expected type Duck, got type Goose with hash 2285691517925918546 (TypeError) Caller: run.rb:12 Definition: /Users/leifg/Documents/tech_talks/sorbet/code/sorbet/herder.rb:7 raise TypeError.new(opts[:pretty_message]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:303:in `call_validation_error_handler' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:286:in `report_error' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:204:in `block in validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/signature.rb:201:in `each_args_value_type' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:201:in `validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/_methods.rb:275:in `block in _on_method_added' from run.rb:12:in `<main>'
  6. Now What? # typed: strict require "sorbet-runtime" class Anatidae extend

    T::Sig sig { returns(String) } def quack raise "Not Implemetned" end sig { returns(String) } def waddle raise "Not Implemetned" end end # typed: strict class Duck < Anatidae extend T::Sig sig { override.returns(String) } def quack "Duck is quacking" end sig { override.returns(String) } def waddle "Duck is waddling" end end class Goose < Anatidae extend T::Sig sig { override.returns(String) } def quack "Goose is quacking" end sig { override.returns(String) } def waddle "Goose is waddling" end end # typed: strict Herder.new.talk(Goose.new) # typed: strict require "./inherited_types" class Herder extend T::Sig sig { params(anatidae: Anatidae).void } def talk(anatidae) puts anatidae.quack end sig { params(anatidaes: T::Array[Anatidae]).void } def move_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.waddle } end sig { params(anatidaes: T::Array[Anatidae]).void } def talk_to_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.quack } end end
  7. Now What? # typed: strict Herder.new.talk(Goose.new) # typed: strict require

    “./abstract_class” class Herder extend T::Sig sig { params(anatidae: Anatidae).void } def talk(anatidae) puts anatidae.quack end sig { params(anatidaes: T::Array[Anatidae]).void } def move_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.waddle } end sig { params(anatidaes: T::Array[Anatidae]).void } def talk_to_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.quack } end end # typed: ignore require "sorbet-runtime" class Anatidae extend T::Sig extend T::Helpers abstract! sig { abstract.returns(String) } def quack; end sig { abstract.returns(String) } def waddle; end end class Duck < Anatidae extend T::Sig sig { override.returns(String) } def quack "Duck is quacking" end sig { override.returns(String) } def waddle "Duck is waddling" end end class Goose < Anatidae extend T::Sig sig { override.returns(String) } def quack "Goose is quacking" end sig { override.returns(String) } def waddle "Goose is waddling" end end
  8. Now What? # typed: strict require "sorbet-runtime" module MakesQuack extend

    T::Sig extend T::Helpers interface! sig { abstract.returns(String) } def quack; end end module MakesWaddle extend T::Sig extend T::Helpers interface! sig { abstract.returns(String) } def waddle; end end class Duck extend T::Sig include MakesQuack include MakesWaddle sig { override.returns(String) } def quack "Duck is quacking" end sig { override.returns(String) } def waddle "Duck is waddling" end end class Goose extend T::Sig include MakesQuack include MakesWaddle sig { override.returns(String) } def quack "Goose is quacking" end sig { override.returns(String) } def waddle "Goose is waddling" end end # typed: strict require "./interfaces" class Herder extend T::Sig sig { params(anatidae: MakesQuack).void } def talk(anatidae) puts anatidae.quack end sig { params(anatidaes: T::Array[MakesWaddle]).void } def move_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.waddle } end sig { params(anatidaes: T::Array[MakesQuack]).void } def talk_to_flock(anatidaes) anatidaes.each { |anatidae| puts anatidae.quack } end end # typed: strict Herder.new.talk(Goose.new) Strategy Design Pattern
  9. class FilePrinter extend T::Sig sig { params(file: T.any(File, StringIO)).void }

    def initialize(file) @file = file end sig { void } def print_with_emojis @file.each_line do |line| puts "#{line} \u{1f525}" end end end
  10. Static Type Checker that Analyzes Files && Runtime Environment that

    does type checks on execution Sorbet is …
  11. class User < ActiveRecord::Base end How Many Methods Does this

    Class Have? irb(main):001:0> User.new.public_methods.count => 581
  12. Some Examples [:clear_email_change, :email, :email=, :email?, :email_before_last_save, :email_before_type_cast, :email_came_from_user?, :email_change,

    :email_change_to_be_saved, :email_changed?, :email_for_database, :email_in_database, :email_previous_change, :email_previously_changed?, :email_previously_was, :email_was, :email_will_change!, :restore_email!, :saved_change_to_email, :saved_change_to_email?, :will_save_change_to_email?] [:update, :update!, :save, :save!, :update_attribute, :update_column, :update_columns, :new_record?, :previously_new_record?, :saved_chanes, :saved_changes? ] [:after_create_commit, :after_create_commit, :around_create, :before_create, :create, :create!, :create_or_find_by, :create_or_find_by!, :create_with, :find_or_create_by, :find_or_create_by!, :first_or_create, :first_or_create!, :timestamp_attributes_for_create_in_model]
  13. Introducing Tapioca $ bin/tapioca dsl Loading Rails application... Done Loading

    DSL compiler classes... Done Compiling DSL RBI files... ActiveRecord::SchemaMigration Pluck (1.7ms) SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC ... create sorbet/rbi/dsl/devise_controller.rbi create sorbet/rbi/dsl/generated_url_helpers_module.rbi create sorbet/rbi/dsl/ generated_path_helpers_module.rbi create sorbet/rbi/dsl/has_scope.rbi create sorbet/rbi/dsl/user.rbi Done Checking generated RBI files... Done No errors found All operations performed in working directory. Please review changes and commit them.
  14. $ tree -A sorbet/ sorbet/ └── rbi └── dsl ├──

    devise │ ├── confirmations_controller.rbi │ ├── failure_app.rbi │ ├── mailer.rbi │ ├── models │ │ └── authenticatable.rbi │ ├── omniauth_callbacks_controller.rbi │ ├── passwords_controller.rbi │ ├── registrations_controller.rbi │ ├── sessions_controller.rbi │ └── unlocks_controller.rbi ├── devise_controller.rbi ├── fast_jsonapi │ └── object_serializer.rbi ├── generated_path_helpers_module.rbi ├── generated_url_helpers_module.rbi ├── has_scope.rbi ├── sessions_controller.rbi └── user.rbi
  15. # typed: true # DO NOT EDIT MANUALLY # This

    is an autogenerated file for dynamic methods in `User`. # Please instead update this file by running `bin/tapioca dsl User`. class User include GeneratedAttributeMethods extend CommonRelationMethods extend GeneratedRelationMethods module GeneratedAttributeMethods sig { returns(::String) } def email; end sig { params(value: ::String).returns(::String) } def email=(value); end wc -l sorbet/rbi/dsl/user.rbi 1337
  16. # typed: strict class Money < T::Struct extend T::Sig const

    :amount, Integer # amount in cents const :currency, String # ISO 4217 currency code end Typed Structs irb(main):001:0> Money.new(amount: 499_95, currency: "USD") => <Money amount=49995 currency=“USD"> irb(main):002:0> money.with(currency: "EUR") => <Money amount=49995 currency="EUR"> irb(main):003:0> money.with(amount: money.amount + 5) => <Money amount=50000 currency=“USD”>
  17. Enums irb(main):013:0> open_status = Status::Open => #<Status::Open> irb(main):014:0> Status.deserialize("open") =>

    #<Status::Open> # typed: strict class Status < T::Enum enums do Open = new Rejected = new Completed = new end end
  18. irb(main):001:0> Status::Completed.final? => true class Status < T::Enum extend T::Sig

    enums do Open = new Rejected = new Completed = new end sig { returns(T::Boolean) } def final? case self when Rejected, Completed true when Open false else T.absurd(self) end end end Exhaustive Checking
  19. class Status < T::Enum extend T::Sig enums do Draft =

    new Open = new Rejected = new Completed = new end sig { returns(T::Boolean) } def final? case self when Rejected, Completed true when Open false else T.absurd(self) end end end Exhaustive Checking status.rb:21: Control flow could reach T.absurd because the type Status::Draft wasn't handled https://srb.help/7026 21 | T.absurd(self) ^^^^^^^^^^^^^^ Got Status::Draft originating from: status.rb:16: 16 | when Rejected, Completed ^^^^^^^^^ status.rb:18: 18 | when Open ^^^^
  20. The Ruby Approach class SurveyUser < T::Struct extend T::Sig const

    :name, String const :email, T.nilable(String) const :phone, T.nilable(String) sig { returns(T::Boolean) } def valid? return true if email.nil? && phone.nil? return true if email && phone return true if email && phone.nil? false end sig { void } def send_confirmation_email raise ArgumentError, "Invalid SurveyUser" unless valid? user_email = email return if user_email.nil? ConfirmationEmail.new(user_email).deliver_later end end
  21. Discriminated Unions module ContactDetails extend T::Helpers sealed! class Anonymous <

    T::Struct include ContactDetails end class EmailOnly < T::Struct include ContactDetails const :email, String end class EmailAndPhone < T::Struct include ContactDetails const :email, String const :phone, String end end
  22. class SurveyUser < T::Struct extend T::Sig const :name, String const

    :contact_details, ContactDetails sig { void } def send_confirmation_email user_contact_details = contact_details case user_contact_details when ContactDetails::Anonymous # do nothing when ContactDetails::EmailOnly, ContactDetails::EmailAndPhone ConfirmationEmail.new(user_contact_details.email).deliver_later else T.absurd(user_contact_details) end end end Exhaustive Checking "Making Impossible States Impossible" by Richard Feldman
  23. class Herder extend T::Sig sig { params(duck: Duck).void } def

    talk(duck) puts duck.quack end sig { params(ducks: T::Array[Duck]).void } def talk_to_flock(ducks) ducks.each { |duck| talk(duck) } end end Generics flock = [ Duck.new, Duck.new, Goose.new ] Herder.new.talk_to_flock(flock) run.rb:8: Expected T::Array[Duck] but found [Duck, Duck, Goose] for argument ducks https://srb.help/7002 8 |Herder.new.talk_to_flock(flock) ^^^^^ Expected T::Array[Duck] for argument ducks of method Herder#talk_to_flock: herder.rb:13: 13 | sig { params(ducks: T::Array[Duck]).void } ^^^^^ Got [Duck, Duck, Goose] (3-tuple) originating from: run.rb:6: 6 |flock = [ Duck.new, Duck.new, Goose.new ] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:296:in `call_validation_error_handler_default’: Parameter 'duck': Expected type Duck, got type Goose with hash 2285691517925918546 (TypeError) Caller: run.rb:12 Definition: /Users/leifg/Documents/tech_talks/sorbet/code/sorbet/herder.rb:7 raise TypeError.new(opts[:pretty_message]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:303:in `call_validation_error_handler' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:286:in `report_error' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:204:in `block in validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/signature.rb:201:in `each_args_value_type' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:201:in `validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/_methods.rb:275:in `block in _on_method_added' from run.rb:12:in `<main>'
  24. class Herder extend T::Sig sig { params(duck: Duck).void } def

    talk(anatidae) puts anatidae.quack end sig { params(ducks: T::Array[Duck]).void } def talk_to_flock(ducks) ducks.each { |duck| talk(duck) } end end ❯ srb No errors! Great job. Generics flock = Array.new(3) { Goose.new } Herder.new.talk_to_flock(flock) lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:296:in `call_validation_error_handler_default’: Parameter 'duck': Expected type Duck, got type Goose with hash 2285691517925918546 (TypeError) Caller: run.rb:12 Definition: /Users/leifg/Documents/tech_talks/sorbet/code/sorbet/herder.rb:7 raise TypeError.new(opts[:pretty_message]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/configuration.rb:303:in `call_validation_error_handler' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:286:in `report_error' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:204:in `block in validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/signature.rb:201:in `each_args_value_type' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/call_validation.rb:201:in `validate_call' lib/ruby/gems/3.2.0/gems/sorbet-runtime-0.5.10793/lib/types/private/methods/_methods.rb:275:in `block in _on_method_added' from run.rb:12:in `<main>'
  25. # typed: strict module FetchUserId extend T::Sig extend self sig

    { params(email: String).returns(Integer) } def by_email(email) user = User.find_by!(email: email) user.id end end ❯ srb app/helpers/testing.rb:10: Expected Integer but found T.nilable(Integer) for method result type https://srb.help/7005 10 | user.id ^^^^^^^ Expected Integer for result type of method fetch_id: app/helpers/testing.rb:7: 7 | def fetch_id(email) ^^^^^^^^^^^^^^^^^^^ Got T.nilable(Integer) originating from: app/helpers/testing.rb:10: 10 | user.id ^^^^^^^ Autocorrect: Use -a to autocorrect app/helpers/testing.rb:10: Replace with T.must(user.id) 10 | user.id Dynamic Ecosystem
  26. # typed: strict module Testing extend T::Sig sig { params(email:

    String).returns(Integer) } def fetch_id(email) user = User.find_by!(email: email) T.must(user.id) end end Dynamic Ecosystem
  27. Serialization # typed: strict class Money < T::Struct extend T::Sig

    const :amount, Integer const :currency, String end irb(main):001:0> m = Money.new(amount: 100_00, currency: "USD") 
 => <Money amount=10000 currency="USD"> irb(main):002:0> serialized = m.serialize 
 => {"amount"=>10000, “currency"=>"USD"} irb(main):003:0> Money.from_hash(serialized) 
 => <Money amount=10000 currency="USD"> 👍
  28. Serialization # typed: strict class Money < T::Struct extend T::Sig

    const :amount, Integer const :currency, String end irb(main):080:0> serialized = {amount: 100_00, currency: "USD"} 
 => {:amount=>10000, :currency=>"USD"} irb(main):003:0> Money.from_hash(serialized) gems/sorbet-runtime-0.5.10793/lib/types/props/serializable.rb:70:in `rescue in deserialize': Error in Money#__t_props_generated_deserialize: Tried to deserialize a required prop from a nil value. It's possible that a nil value exists in the database, so you should provide a `default: or factory:` for this prop (see go/optional for more details). If this is already the case, you probably omitted a required prop from the `fields:` option when doing a partial load. (RuntimeError) 👎
  29. class Order < T::Struct const :id, String const :amount, Money

    const :approved_at, T.nilable(Time) const :refundable, T::Boolean end irb(main):001:1* o = Order.new( irb(main):002:1* id: "1", irb(main):003:1* amount: m, irb(main):004:1* approved_at: Time.now, irb(main):005:1* refundable: false, irb(main):005:0> ) => <Order amount=<Money amount=10000 currency="USD"> approved_at=2023-04-29 11:24:30.872848 -0700 id="1" refundable=false> irb(main):001:0> serialized = o1.serialize.to_json 
 => {“id":"1","amount" {"amount":10000,"currency":"USD"}, 
 "approved_at":"2023-04-29 11:24:30 -0700","refundable":false} irb(main):002:0> o = Order.from_hash(JSON.parse(serialized)) 
 => <Order amount=<Money amount=10000 currency="USD"> approved_at="2023-04-29 11:24:30 -0700" id="1" refundable=false> irb(main):003:0> o.approved_at.class 
 => String Serialization 👎
  30. class Order < T::Struct const :id, String const :amount, Money

    const :approved_at, T.nilable(Time) const :refundable, T::Boolean end Serialization irb(main):001:0> require "sorbet-coerce" irb(main):002:0> o = TypeCoerce[Order].new.from(JSON.parse(serialized)) irb(main):003:0> o.approved_at.class => Time
  31. Community class Money < T::Struct const :amount, Integer const :currency,

    String end class Order < T::Struct const :value, Money end o = Order.new(value: Money.new(amount: 100_00, currency: "USD")) o.with(value: Money.new(amount: 200_00, currency: "USD")) /gems/ruby/3.1.0/gems/sorbet-runtime-0.5.10526/lib/types/props/serializable.rb:72:in `rescue in rescue in deserialize': Error in Order#__t_props_generated_deserialize: <Money amount=20000, currency="USD"> provided to from_hash (TypeError) at line 8 in: found = 1 val = hash["value"] @value = if val.nil? found -= 1 unless hash.key?("value") self.class.decorator.raise_nil_deserialize_error("value") else begin Money.from_hash(val) rescue NoMethodError => e raise_deserialization_error( :value, val, e, ) val end /gems/ruby/3.1.0/gems/sorbet-runtime-0.5.10526/lib/types/props/serializable.rb:70:in `rescue in deserialize': Error in Order#__t_props_generated_deserialize: <Money amount=20000, currency="USD"> provided to from_hash (ArgumentError) at line 8 in: found = 1 val = hash["value"] @value = if val.nil?
  32. Existing Codebases T::Configuration.call_validation_error_handler = lambda do |signature, opts| if Rails.env.production?

    Honeybadger.notify(opts[:pretty_message]) else raise TypeError.new(opts[:pretty_message]) end end Runtime Con fi guration Sorbet Docs
  33. - Slide 1: Photo by Anton on Unsplash - Slide

    3: Photo by Timothy Dykes on Unsplash - Slide 6: Image by Fathromi Ramdlon from Pixabay - Slide 11: Image by Nikin from Pixabay - Slide 12: Image by Tom from Pixabay - Slide xx: Photo by Joanna Kosinska on Unsplash - Slide 19: Photo by Siddharth Salve on Unsplash - Slide 31: Photo by MongeDraws Attributions