├── TODO.md ├── Gemfile ├── lib ├── affect │ ├── version.rb │ ├── cont.rb │ └── fiber.rb └── affect.rb ├── Gemfile.lock ├── examples ├── logging.rb ├── greet.rb ├── pat.rb └── fact.rb ├── CHANGELOG.md ├── affect.gemspec ├── LICENSE ├── .gitignore ├── test └── test_affect.rb └── README.md /TODO.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec -------------------------------------------------------------------------------- /lib/affect/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Affect 4 | VERSION = '0.3' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | affect (0.3) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | minitest (5.11.3) 10 | 11 | PLATFORMS 12 | ruby 13 | 14 | DEPENDENCIES 15 | affect! 16 | minitest (= 5.11.3) 17 | 18 | BUNDLED WITH 19 | 1.17.2 20 | -------------------------------------------------------------------------------- /examples/logging.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'affect' 3 | 4 | def mul(x, y) 5 | # assume LOG is a global logger object 6 | Affect :log, "called with #{x}, #{y}" 7 | x * y 8 | end 9 | 10 | Affect.run { 11 | puts "Result: #{ mul(2, 3) }" 12 | }.on(:log) { |message| 13 | puts "#{Time.now} #{message} (this is a log message)" 14 | }.() -------------------------------------------------------------------------------- /examples/greet.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'affect' 3 | 4 | def main 5 | Affect :prompt 6 | name = Affect :input 7 | Affect :output, "Hi, #{name}! I'm Affected Ruby!" 8 | end 9 | 10 | ctx = Affect.on( 11 | prompt: -> { puts "Enter your name: " }, 12 | input: -> { gets.chomp }, 13 | output: ->(msg) { puts msg } 14 | ) 15 | 16 | ctx.() { main } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.3 2019-08-29 2 | -------------- 3 | 4 | * Add support for passing blocks to effect handlers 5 | 6 | 0.2 2019-08-26 7 | -------------- 8 | 9 | * Add alternative effects implementation using fibers 10 | * Add implementation of delimited continuations 11 | * Rewrite, change API to use capture/perform 12 | 13 | 0.1 2019-07-30 14 | -------------- 15 | 16 | * First implementation -------------------------------------------------------------------------------- /examples/pat.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'affect' 3 | 4 | def pattern_count(pattern) 5 | total_count = 0 6 | found_count = 0 7 | while (line = Affect.gets) 8 | total_count += 1 9 | found_count += 1 if line =~ pattern 10 | end 11 | Affect.log "found #{found_count} occurrences in #{total_count} lines" 12 | found_count 13 | end 14 | 15 | Affect.on( 16 | gets: -> { STDIN.gets }, 17 | log: ->(msg) { STDERR.puts "#{Time.now} #{msg}" } 18 | ).() { 19 | pattern = /#{ARGV[0]}/ 20 | count = pattern_count(pattern) 21 | puts count 22 | } -------------------------------------------------------------------------------- /examples/fact.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'affect' 3 | 4 | def fact(x) 5 | Affect :log, "calculating factorial for #{x}" 6 | (x <= 1) ? 1 : x * fact(x - 1) 7 | end 8 | 9 | def main 10 | Affect :prompt 11 | x = Affect :input 12 | result = fact(x) 13 | Affect :output, "The factorial of result is #{result}" 14 | end 15 | 16 | ctx = Affect.on( 17 | prompt: -> { puts "Enter a number: " }, 18 | input: -> { gets.chomp.to_i }, 19 | output: ->(msg) { puts msg }, 20 | log: ->(msg) { puts "#{Time.now} #{msg}" } 21 | ) 22 | 23 | ctx.() { loop { main } } -------------------------------------------------------------------------------- /affect.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative './lib/affect/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'affect' 6 | s.version = Affect::VERSION 7 | s.licenses = ['MIT'] 8 | s.summary = 'Affect: Algebraic Effects for Ruby' 9 | s.author = 'Sharon Rosner' 10 | s.email = 'ciconia@gmail.com' 11 | s.files = `git ls-files`.split 12 | s.homepage = 'http://github.com/digital-fabric/affect' 13 | s.metadata = { 14 | "source_code_uri" => "https://github.com/digital-fabric/affect" 15 | } 16 | s.rdoc_options = ["--title", "affect", "--main", "README.md"] 17 | s.extra_rdoc_files = ["README.md"] 18 | s.require_paths = ["lib"] 19 | 20 | # s.add_runtime_dependency 'modulation', '~>0.25' 21 | 22 | s.add_development_dependency 'minitest', '5.11.3' 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sharon Rosner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/affect/cont.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # adapted from: 4 | # https://github.com/mveytsman/DelimR/blob/master/lib/delimr.rb 5 | 6 | require 'continuation' 7 | 8 | module Affect 9 | module Cont 10 | extend self 11 | 12 | # holds objects of the form [bool, Continuation] 13 | # where bool siginifies a 14 | @@stack = [] 15 | 16 | def abort(v) 17 | (@@stack.pop)[1].(v) 18 | end 19 | 20 | def capture(&block) 21 | callcc { |outer| 22 | @@stack << [true, outer] 23 | abort(block.()) 24 | } 25 | end 26 | 27 | def escape 28 | callcc do |esc| 29 | unwound_continuations = unwind_stack 30 | cont_proc = lambda { |v| 31 | callcc do |ret| 32 | @@stack << [true, ret] 33 | unwound_continuations.each { |c| @@stack << [nil, c] } 34 | esc.call(v) 35 | end 36 | } 37 | abort(yield(cont_proc)) 38 | end 39 | end 40 | 41 | def unwind_stack 42 | unwound = [] 43 | while @@stack.last && !(@@stack.last)[0] 44 | unwound << (@@stack.pop)[1] 45 | end 46 | unwound.reverse 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | *.bridgesupport 21 | build-iPhoneOS/ 22 | build-iPhoneSimulator/ 23 | 24 | ## Specific to RubyMotion (use of CocoaPods): 25 | # 26 | # We recommend against adding the Pods directory to your .gitignore. However 27 | # you should judge for yourself, the pros and cons are mentioned at: 28 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 29 | # 30 | # vendor/Pods/ 31 | 32 | ## Documentation cache and generated files: 33 | /.yardoc/ 34 | /_yardoc/ 35 | /doc/ 36 | /rdoc/ 37 | 38 | ## Environment normalization: 39 | /.bundle/ 40 | /vendor/bundle 41 | /lib/bundler/man/ 42 | 43 | # for a library or gem, you might want to ignore these files since the code is 44 | # intended to run in multiple environments; otherwise, check them in: 45 | # Gemfile.lock 46 | # .ruby-version 47 | # .ruby-gemset 48 | 49 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 50 | .rvmrc 51 | -------------------------------------------------------------------------------- /lib/affect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Affect module 4 | module Affect 5 | extend self 6 | 7 | # Implements an effects context 8 | class Context 9 | def initialize(handlers = nil, &block) 10 | @handlers = handlers || { nil => block || -> {} } 11 | end 12 | 13 | attr_reader :handlers 14 | 15 | def handler_proc 16 | proc { |effect, *args| handle(effect, *args) } 17 | end 18 | 19 | def perform(effect, *args, &block) 20 | handler = find_handler(effect) 21 | if handler 22 | call_handler(handler, effect, *args, &block) 23 | elsif @parent 24 | @parent.perform(effect, *args, &block) 25 | else 26 | raise "No handler found for #{effect.inspect}" 27 | end 28 | end 29 | 30 | def find_handler(effect) 31 | @handlers[effect] || @handlers[effect.class] || @handlers[nil] 32 | end 33 | 34 | def call_handler(handler, effect, *args, &block) 35 | if handler.arity.zero? 36 | handler.call(&block) 37 | elsif args.empty? 38 | handler.call(effect, &block) 39 | else 40 | handler.call(*args, &block) 41 | end 42 | end 43 | 44 | @@current = nil 45 | def self.current 46 | @@current 47 | end 48 | 49 | def capture 50 | @parent, @@current = @@current, self 51 | catch(:escape) { yield } 52 | ensure 53 | @@current = @parent 54 | end 55 | 56 | def escape(value = nil) 57 | throw :escape, (block_given? ? yield : value) 58 | end 59 | end 60 | 61 | def capture(*args, &block) 62 | block, handlers = block_and_handlers_from_args(*args, &block) 63 | handlers = { nil => handlers } if handlers.is_a?(Proc) 64 | Context.new(handlers).capture(&block) 65 | end 66 | 67 | def block_and_handlers_from_args(*args, &block) 68 | case args.size 69 | when 1 then block ? [block, args.first] : [args.first, nil] 70 | when 2 then args 71 | else [block, nil] 72 | end 73 | end 74 | 75 | def perform(effect, *args, &block) 76 | unless (ctx = Context.current) 77 | raise 'perform called outside capture block' 78 | end 79 | 80 | ctx.perform(effect, *args, &block) 81 | end 82 | 83 | def escape(value = nil, &block) 84 | unless (ctx = Context.current) 85 | raise 'escape called outside capture block' 86 | end 87 | 88 | ctx.escape(value, &block) 89 | end 90 | 91 | def respond_to_missing?(*) 92 | true 93 | end 94 | 95 | def method_missing(*args) 96 | perform(*args) 97 | end 98 | end 99 | 100 | # Kernel extensions 101 | module Kernel 102 | def Affect(handlers = nil, &block) 103 | Affect::Context.new(handlers, &block) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/affect/fiber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fiber' 4 | 5 | # Affect module 6 | module Affect 7 | module Fiber 8 | extend self 9 | 10 | class Intent 11 | def initialize(*args) 12 | @args = args 13 | end 14 | 15 | attr_reader :args 16 | end 17 | 18 | class Escape < Intent 19 | def initialize(&block) 20 | @block = block 21 | end 22 | 23 | def call(*args) 24 | @block.(*args) 25 | end 26 | end 27 | 28 | def capture(*args, &block) 29 | block, handler = case args.size 30 | when 1 then block ? [block, args.first] : [args.first, nil] 31 | when 2 then args 32 | else [block, nil] 33 | end 34 | 35 | f = ::Fiber.new(&block) 36 | v = f.resume 37 | loop do 38 | break v unless f.alive? && v.is_a?(Intent) 39 | 40 | if v.is_a?(Escape) 41 | break v.() 42 | else 43 | v = f.resume(handler.(*v.args)) 44 | end 45 | end 46 | end 47 | 48 | def perform(*args) 49 | ::Fiber.yield Intent.new(*args) 50 | rescue FiberError 51 | raise RuntimeError, 'perform called outside of capture' 52 | end 53 | 54 | def escape(value = nil, &block) 55 | block ||= proc { value } 56 | ::Fiber.yield Escape.new(&block) 57 | rescue FiberError 58 | raise RuntimeError, 'escape called outside of capture' 59 | end 60 | 61 | def method_missing(*args) 62 | perform(*args) 63 | end 64 | 65 | class Context 66 | def initialize(handlers = nil, &block) 67 | @handlers = handlers || { nil => block || -> { } } 68 | end 69 | 70 | attr_reader :handlers 71 | 72 | def handler_proc 73 | proc { |effect, *args| handle(effect, *args) } 74 | end 75 | 76 | def handle(effect, *args) 77 | handler = find_handler(effect) 78 | if handler 79 | call_handler(handler, effect, *args) 80 | else 81 | begin 82 | ::Fiber.yield Intent.new(effect, *args) 83 | rescue FiberError 84 | raise RuntimeError, "No handler found for #{effect.inspect}" 85 | end 86 | end 87 | end 88 | 89 | def find_handler(effect) 90 | @handlers[effect] || @handlers[effect.class] || @handlers[nil] 91 | end 92 | 93 | def call_handler(handler, effect, *args) 94 | if handler.arity == 0 95 | handler.call 96 | elsif args.empty? 97 | handler.call(effect) 98 | else 99 | handler.call(*args) 100 | end 101 | end 102 | 103 | def capture(&block) 104 | Affect.capture(block, handler_proc) 105 | end 106 | end 107 | end 108 | end 109 | 110 | module Kernel 111 | def Affect(handlers = nil, &block) 112 | Affect::Context.new(handlers, &block) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /test/test_affect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'bundler/setup' 5 | require 'affect' 6 | 7 | class AffectTest < Minitest::Test 8 | include Affect 9 | 10 | def test_capture_with_different_arities 11 | assert_equal :foo, capture { :foo } 12 | assert_equal :foo, capture(-> { :foo }) 13 | 14 | assert_equal :bar, capture(-> { perform(:foo) }, ->(e) { e == :foo && :bar }) 15 | 16 | end 17 | 18 | def test_escape 19 | assert_raises(RuntimeError) { escape(:foo) } 20 | assert_raises(RuntimeError) { escape { :foo } } 21 | 22 | assert_equal :bar, capture { [:foo, escape(:bar)] } 23 | assert_equal :baz, capture { [:foo, escape { :baz }] } 24 | end 25 | 26 | def test_effect_handler_dsl 27 | v = 1 28 | ctx = Affect( 29 | get: -> { v }, 30 | set: ->(x) { v = x } 31 | ) 32 | 33 | assert_kind_of Affect::Context, ctx 34 | 35 | final = ctx.capture { 36 | [ 37 | perform(:get), 38 | perform(:set, 2), 39 | perform(:get), 40 | Affect.get, 41 | Affect.set(3), 42 | Affect.get 43 | ] 44 | } 45 | 46 | assert_equal [1, 2, 2, 2, 3, 3], final 47 | end 48 | 49 | def test_context_missing_handler 50 | assert_raises RuntimeError do 51 | Affect(foo: -> { :bar }).capture { perform :baz } 52 | end 53 | end 54 | 55 | def test_context_wildcard_handler 56 | ctx = Affect do |e| e + 1; end 57 | assert_equal 3, ctx.capture { perform(2) } 58 | end 59 | 60 | def test_context_handler_with_block 61 | assert_equal :bar, Affect(foo: -> &block { block.() }).capture { 62 | perform(:foo) { :bar } 63 | } 64 | end 65 | 66 | def test_that_contexts_can_be_nested 67 | results = [] 68 | 69 | ctx2 = Affect(bar: -> { results << :baz }) 70 | ctx1 = Affect( 71 | foo: -> { results << :foo }, 72 | bar: -> { results << :bar } 73 | ) 74 | 75 | ctx1.capture { 76 | Affect.foo 77 | Affect.bar 78 | 79 | ctx2.capture { 80 | Affect.foo 81 | Affect.bar 82 | } 83 | } 84 | 85 | assert_equal([:foo, :bar, :foo, :baz], results) 86 | end 87 | 88 | def test_that_escape_provides_return_value_of_capture 89 | assert_equal 42, capture { 2 * escape { 42 } } 90 | end 91 | 92 | class I1; end 93 | class I2; end 94 | 95 | def test_that_intent_instances_are_handled_correctly 96 | results = [] 97 | Affect( 98 | I1 => -> { results << :i1 }, 99 | I2 => -> { results << :i2 } 100 | ).capture { 101 | perform I1.new 102 | perform I2.new 103 | } 104 | 105 | assert_equal([:i1, :i2], results) 106 | end 107 | 108 | # doesn't work with callback-based affect 109 | def test_that_capture_can_work_across_fibers_with_transfer 110 | require 'fiber' 111 | f1 = Fiber.new { |f| escape(:foo) } 112 | f2 = Fiber.new { f1.transfer(f2) } 113 | 114 | # assert_equal :foo, capture { f2.resume } 115 | end 116 | 117 | # doesn't work with fiber-based Affect 118 | def test_that_capture_can_work_across_fibers_with_yield 119 | f1 = Fiber.new { |f| escape(:foo) } 120 | f2 = Fiber.new { f1.resume } 121 | 122 | # assert_equal :foo, capture { f2.resume } 123 | end 124 | end 125 | 126 | class ContTest < Minitest::Test 127 | require 'affect/cont' 128 | 129 | Cont = Affect::Cont 130 | 131 | def test_that_continuation_is_provided_to_escape 132 | k = Cont.capture { 2 * Cont.escape { |cont| cont } } 133 | assert_kind_of Proc, k 134 | end 135 | 136 | def test_that_continuation_completes_the_computation_in_capture 137 | k = Cont.capture { 2 * Cont.escape { |cont| cont } } 138 | assert_equal 12, k.(6) 139 | end 140 | 141 | def test_that_continuation_can_be_called_multiple_times 142 | k = Cont.capture { 2 * Cont.escape { |cont| cont } } 143 | assert_equal 4, k.(2) 144 | assert_equal 6, k.(3) 145 | assert_equal 8, k.(4) 146 | end 147 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Affect - algebraic effects for Ruby 2 | 3 | [INSTALL](#installing-affect) | 4 | [TUTORIAL](#getting-started) | 5 | [EXAMPLES](examples) | 6 | 7 | > Affect | əˈfɛkt | verb [with object] have an effect on; make a difference to. 8 | 9 | ## What is Affect 10 | 11 | Affect is a tiny Ruby gem providing a way to isolate and handle side-effects in 12 | functional programs. Affect implements algebraic effects in Ruby, but can also 13 | be used to implement patterns that are orthogonal to object-oriented 14 | programming, such as inversion of control and dependency injection. 15 | 16 | In addition, Affect includes an alternative implementation of algebraic effects 17 | using Ruby fibers, as well as an implementation of delimited continuations using 18 | `callcc` (currently deprecated). 19 | 20 | > **Note**: Affect does not pretend to be a *complete, theoretically correct* 21 | > implementation of algebraic effects. Affect concentrates on the idea of 22 | > [effect contexts](#the-effect-context). It does not deal with continuations, 23 | > asynchrony, or any other concurrency constructs. 24 | 25 | ## Installing Affect 26 | 27 | ```ruby 28 | # In your Gemfile 29 | gem 'affect' 30 | ``` 31 | 32 | Or install it manually, you know the drill. 33 | 34 | ## Getting Started 35 | 36 | Algebraic effects introduces the concept of effect handlers, little pieces of 37 | code that are provided by the caller, and invoked by the callee using a uniform 38 | interface. An example of algebraic effects might be logging. Normally, if we 39 | wanted to log a certain message to `STDOUT` or to a file, we would do the 40 | following: 41 | 42 | ```ruby 43 | def mul(x, y) 44 | # assume LOG is a global logger object 45 | LOG.info("called with #{x}, #{y}") 46 | x * y 47 | end 48 | 49 | puts "Result: #{ mul(2, 3) }" 50 | ``` 51 | 52 | The act of logging is a side-effect of our computation. We need to have a global 53 | `LOG` object, and we cannot test the functioning of the `mul` method in 54 | isolation. What if we wanted to be able to plug-in a custom logger, or intercept 55 | calls to the logger? 56 | 57 | Affect provides a solution for such problems by implementing a uniform, 58 | composable interface for isolating and handling side effects: 59 | 60 | ```ruby 61 | require 'affect' 62 | 63 | def mul(x, y) 64 | # assume LOG is a global logger object 65 | Affect.perform :log, "called with #{x}, #{y}" 66 | x * y 67 | end 68 | 69 | Affect.capture( 70 | log: { |message| puts "#{Time.now} #{message} (this is a log message)" } 71 | ) { 72 | puts "Result: #{ mul(2, 3) }" 73 | ``` 74 | 75 | In the example above, we replace the call to `LOG.info` with the performance of 76 | an *intent* to log a message. When the intent is passed to `Affect`, the 77 | corresponding handler is called in order to perform the effect. 78 | 79 | In essence, by separating the performance of side effects into effect intents, 80 | and effect handlers, we have separated the what from the how. The `mul` method 81 | is no longer concerned with how to log the message it needs to log. There's no 82 | hardbaked reference to a `LOG` object, and no logging API to follow. Instead, 83 | the *intent* to log a message is passed on to Affect, which in turn runs the 84 | correct handler that actually does the logging. 85 | 86 | ## The effect context 87 | 88 | In Affect, effects are performed and handled using an *effect context*. The 89 | effect context has one or more effect handlers, and is then used to run code 90 | that performs effects, handling effect intents by routing them to the correct 91 | handler. 92 | 93 | Effect contexts are defined using either `Affect()` or the shorthand 94 | `Affect.capture`: 95 | 96 | ```ruby 97 | ctx = Affect(log: -> msg { log_msg(msg) }) 98 | ctx.capture { do_something } 99 | 100 | # or 101 | Affect.capture(log: -> msg { log_msg(msg) }) { do_something } 102 | ``` 103 | 104 | The `Affect.capture` method can be called in different manners: 105 | 106 | ```ruby 107 | Affect.capture(handler_hash) { body } 108 | Affect.capture(handler_proc) { body } 109 | Affect.capture(body, handler_hash) 110 | Affect.capture(body, handler_proc) 111 | ``` 112 | 113 | ... where `body` is the code to be executed, `handler_hash` is a hash of effect 114 | handling procs, and `handler_proc` is a default effect handling proc. 115 | 116 | ### Nested effect contexts 117 | 118 | Effect contexts can be nested. When an effect context does not know how to 119 | handle a certain effect intent, it passes it on to the parent effect context. 120 | If no handler has been found for the effect intent, an error is raised: 121 | 122 | ```ruby 123 | # First effect context 124 | Affect.capture(log: ->(msg) { LOG.info(msg) }) { 125 | Affect.perform :log, 'starting' 126 | # Second effect context 127 | Affect.capture(log: ->(msg) { }) { 128 | Affect.perform :log, 'this message will not be logged' 129 | } 130 | Affect.perform :log, 'stopping' 131 | 132 | Affect.perform :foo # raises an error, as no handler is given for :foo 133 | } 134 | ``` 135 | 136 | 137 | ## Effect handlers 138 | 139 | Effect handlers map different effects to a proc or a callable object. When an 140 | effect is performed, Affect will try to find the relevant effect handler by 141 | looking at its *signature* (given as the first argument), and then matching 142 | first by value, then by class. Thus, the effect signature can be either a value, 143 | or a class (normally used when creating intent classes). 144 | 145 | The simplest, most idiomatic way to define effect handlers is to use symbols as 146 | effect signatures: 147 | 148 | ```ruby 149 | Affect(log: -> msg { ... }, ask: -> { ... }) 150 | ``` 151 | 152 | A catch-all handler can be defined by calling `Affect()` with a block: 153 | 154 | ```ruby 155 | Affect do |eff, *args| 156 | case eff 157 | when :log 158 | ... 159 | when :ask 160 | ... 161 | end 162 | end 163 | ``` 164 | 165 | Note that when using a catch-all handler, no error will be raised for unhandled 166 | effects. 167 | 168 | ## Performing side effects 169 | 170 | Side effects are performed by calling `Affect.perform` or simply 171 | `Affect.` along with one or more parameters: 172 | 173 | ```ruby 174 | Affect.perform :foo 175 | 176 | # or: 177 | Affect.foo 178 | ``` 179 | 180 | Any parameters will be passed along to the effect handler: 181 | 182 | ```ruby 183 | Affect.perform :log, 'my message' 184 | ``` 185 | 186 | Effects intents can be represented using any Ruby object, but in a relatively 187 | complex application might best be represented using classes or structs: 188 | 189 | ```ruby 190 | LogIntent = Struct.new(:msg) 191 | 192 | Affect.perform LogIntent.new('my message') 193 | ``` 194 | 195 | When using symbols as effect signatures, Affect provides a shorthand way to 196 | perform effects by calling methods directly on the `Affect` module: 197 | 198 | ```ruby 199 | Affect.log('my message') 200 | ``` 201 | 202 | ## Other uses 203 | 204 | In addition to isolating side-effects, Affect can be used for other purposes: 205 | 206 | ### Dependency injection 207 | 208 | Affect can also be used for dependency injection. Dependencies can be injected 209 | by providing effect handlers: 210 | 211 | ```ruby 212 | Affect.on(:db) { 213 | get_db_connection 214 | }.() { 215 | process_users(Affect.db.query('select * from users')) 216 | } 217 | ``` 218 | 219 | This is especially useful for testing purposes as described below: 220 | 221 | ### Testing 222 | 223 | One particular benefit of using Affect is the way it facilitates testing. When 224 | mutable state and side-effects are pulled out of methods and into effect 225 | handlers, testing becomes much easier. Side effects can be mocked or tested 226 | in isolation, and dependencies provided through effect handlers can also be 227 | mocked. The following section includes an example of testing with algebraic 228 | effects. 229 | 230 | ## Writing applications using algebraic effects 231 | 232 | Algebraic effects have yet to be adopted by any widely used programming 233 | language, and they remain a largely theoretical subject in computer science. 234 | Their advantages are still to be proven in actual usage. We might discover that 235 | they're completely inadequate as a solution for managing side-effects, or we 236 | might discover new techniques to be used in conjunction with algebraic effects. 237 | 238 | One important principle to keep in mind is that in order to make the best of 239 | algebraic effects, effect handlers need to be pushed to the outside of your 240 | code. In most cases, the effect context will be defined in the entry-point of 241 | your program, rather than somewhere on the inside. 242 | 243 | Imagine a program that counts the occurences of a user-defined pattern in a 244 | given text file: 245 | 246 | ```ruby 247 | require 'affect' 248 | 249 | def pattern_count(pattern) 250 | total_count = 0 251 | found_count = 0 252 | while (line = Affect.gets) 253 | total_count += 1 254 | found_count += 1 if line =~ pattern 255 | end 256 | Affect.log "found #{found_count} occurrences in #{total_count} lines" 257 | found_count 258 | end 259 | 260 | Affect( 261 | gets: -> { Kernel.gets }, 262 | log: -> { |msg| STDERR << "#{Time.now} #{msg}" } 263 | ).capture { 264 | pattern = /#{ARGV[0]}/ 265 | count = pattern_count(pattern) 266 | puts count 267 | } 268 | ``` 269 | 270 | In the above example, the `pattern_count` method, which does the "hard work", 271 | communicates with the outside world through Affect in order to: 272 | 273 | - read a line after line from some input stream 274 | - log an informational message 275 | 276 | Note that `pattern_count` does *not* deal directly with I/O. It does so 277 | exclusively through Affect. Testing the method would be much simpler: 278 | 279 | ```ruby 280 | require 'minitest' 281 | require 'affect' 282 | 283 | class PatternCountTest < Minitest::Test 284 | def test_correct_count 285 | text = StringIO.new("foo\nbar") 286 | 287 | Affect( 288 | gets: -> { text.gets }, 289 | log: -> |msg| {} # ignore 290 | .capture { 291 | count = pattern_count(/foo/) 292 | assert_equal(1, count) 293 | } 294 | end 295 | end 296 | ``` 297 | 298 | ## Contributing 299 | 300 | Affect is a very small library designed to do very little. If you find it 301 | compelling, have encountered any problems using it, or have any suggestions for 302 | improvements, please feel free to contribute issues or pull requests. 303 | --------------------------------------------------------------------------------