ruby picture

RCR 189: Publish/Subscribe Method Visibility

Submitted by minam (Sat Jan 10 00:01:33 UTC 2004)

Abstract

Restricting method visibility is often used to hide the internal functionings of an object from clients. However, there are times when a limited API, visible to some objects but not to all, is desired. This RCR proposes an implementation of a publish/subscribe-based approach to method visibility, which may safely be used in conjunction with Ruby's existing public/private method visibilities.

Problem

Ruby currently has no clean way to selectively export methods from an object. Such selective export is needed when public and private versions of a class's API are desired (as with the State design pattern). The Eiffel language provides one approach to selective export, in which a designer may specify that certain methods are only accessible to specific classes. RCR #187 (which is superceded by this RCR) proposed such a feature be added to Ruby. However, such an approach (as Matz pointed out in his comment to that RCR) is "against the policy of extensibility," meaning that a client that was originally unknown to the designer cannot ever gain access to those selectively exported methods, except by inheriting from any of the classes that were the original targets of the export. Thus, inheritance becomes used solely as a means of subscribing to a particular set of features of a class.

Proposal

Rather than using inheritance to subscribe to an exported API, Matz, in his graduation thesis, created an object-oriented language that used an explicit publish/subscribe approach to selective export. Although his implementation was dependent on static typing, this RCR uses the same idea in a more "Ruby-esque" fashion.

The following example implements the State design pattern (trivially). Consider a system that implements a sequence of integers as a class. Calls to its next method return subsequent values from the sequence. The sequence also allows its state to be saved and restored, via an opaque SequenceState object, thereby allowing the sequence to be restarted from some previous value.

Here is where the selective export is useful. The SequenceState object should be opaque to everyone <i>except the Sequence itself</i> (or to other classes that want to mimic the behavior of a Sequence). The Sequence object must be able to query and modify the values in the SequenceState object that represent the Sequence's internal state.

Here is the implementation of the SequenceState class:


    
  class SequenceState
    def get_counter
      @counter
    end
    def set_counter( counter )
      @counter = counter
    end
    profile :StateAccess, :get_counter, :set_counter
  end

    

Notice the last line; this declares a new profile, called "StateAccess", and indicates that both "get_counter" and "set_counter" should be in this profile. <i>Putting those methods in the profile also implicitly makes those methods private.</i>

The Sequence class is implemented thus:


    
  class Sequence
    def initialize( start=0 )
      @counter = start-1
    end
    def next
      @counter += 1
    end
    def get_state
      SequenceState.new.subscribe( :StateAccess ) do |state|
        state.set_counter @counter
        return state
      end
    end
    def restore_state( state )
      state.subscribe( :StateAccess ) do
        @counter = state.get_counter
      end
    end
  end

    

Notice the "get_state" and "restore_state" methods. In "get_state", a SequenceState object is instantiated. Then, the caller subscribes to the StateAccess profile of the new object, which gives the corresponding block access to the "set_counter" method of the state object.

In restore_state, the caller subscribes to the StateAccess profile of the state object, which gives them access to the "get_counter" method of the state, thus allowing them to restore the value of the counter.

Analysis

The impact of this feature on the rest of the language would be negligible. Those who would not want to use this feature would not be constrained to do anything special to avoid it--the default would be as it currently is, with methods all being public unless explicitly made protected or private. Those developers who desire this feature may use it without clients either knowing or caring, unless the clients desire to access those methods that require subscription.

It may be argued that this kind of visibility limitation is no limitation at all, since any object at all may subscribe to a service, if it knows about the service. However, the point (in this case) is not to limit visibility. Designers still have access to private and protected access modes, which prevent outside access to methods so annotated. Selective export is not about preventing access, but <i>controlling</i> access. It ensures that the clients must know the proper "protocol" for communicating with an object.

The reference implementation given in the following section has an interesting side effect, which may or may not be desirable (further discussion should help to clarify this). The side effect is this: any subscription causes all associated methods to be public during the life of the associated block. The implications of this are twofold:

  <li>If the publishing object is passed to another method from within the block, all of the subscribed methods will be publicly accessible within the called method.</li>
  <li>If multiple threads are accessing the publishing object simultaneously, and one of them subscribes to selectively exported methods, the other thread will have access to those methods as well.</li>

In general, I suspect the first side effect is desirable, whereas the second is not. It should also be noted that eliminating the first side-effect would probably result in eliminating the second.

Given that the reference implementation is functional, it may be asked why this RCR is necessary (since the same functionality is now available as a module). There are two reasons:

  <li>Although it may be implemented completely in Ruby, there are limitations to such an implementation.  To avoid undesirable side-effects (like inadvertantly granting a parallel thread access to published methods), it may be necessary to implement part, if not all, of the functionality of this in C--perhaps even making minor modifications to the interpreter itself.</li>
  <li>Making such functionality a standard part of the Ruby distribution--whether it is implemented purely in Ruby or not--would encourage developers to make use of it.  If it must be distributed with every project that uses it, or if it must be installed before projects that use it may be installed, it makes it more difficult for it to be properly adopted.</li>

Implementation

The reference implementation is available on the RAA, and is called >pubsub. It is implemented in pure Ruby, and thus suffers from a few drawbacks (such as the problem with thread-safety mentioned in the analysis section). Nevertheless, it functions sufficiently well that I would probably use it if this RCR fails to be accepted.

The module referenced above is implemented by reopening Object and adding functionality to it, thereby granting both profile and subscribe to every class. It might also be implemented as a module which is included by those classes that need it; this approach might be more desirable if the Object class needs to be kept "lean".

ruby picture
Comments Current voting

Somewhat in opposition of the feature. Not because I don't like it, but because I think it should be more encompassing. The publish visibility needs to extend to aliases, modules and classes, too. And it needs to also consider redefinitions of methods (so as to allow publishing or not changes to say, a base class). Also, it needs to support a non-block syntax so as to encompass a similar functionality similar to David Black's behaviors but in both a thread safe manner and with a more succint syntax.


Using a proxy for access

I think "publish" is a better class method name than "profile", as it is easily associated with the publish/subscribe mechanism, and is a better fit with the existing method visibility modifiers (public, protected, private).

Overall naming suggestion:

  • Object.publish <=-was "profile"
  • Object.has_published?
  • Object.get_published_profiles
  • Object.get_published_methods <=-was "get_profile"
  • Object#subscribe
  • Object#published_profiles
  • Object#published_methods (for a given profile)
  • class PublishSubscribeProxy
  • class NoPublishedProfileError <=-exception)

          
  class SomeClass
    def a_method(val)
      val * 2
    end
    def another_method(val)
      val * 3
    end
    publish :AProfile, :a_method, :another_method
  end

          

Another thing that might be an improvement is to access the published methods via a PublishSubscribeProxy object that is accessed by an instance method having the same name as the profile defined by "publish". The call then looks like "some_instance.AProfile.a_method(42)", as in the following example:


          
  sc=SomeClass.new
  sc.subscribe(:AProfile) do
    sc.AProfile.a_method(42)        #==> returns 84
    sc.AProfile.another_method(11)  #==> returns 33
    sc.AProfile.published_methods   #==> returns ["a_method", "another_method"]
  end

          

I think that this way of accessing published methods is clearer, because you have to state the profile thru which you want to access the published methods.

Note that the instance method used to access the proxy object (called using "sc.AProfile" in the above example) is private, but becomes public when the profile is subscribed to. In other words, the proxy object is hidden (private) until subscribed to.

Having the call done via this syntax seems to make those mentioned side-effects less of an issue, because the associated methods are not exposed directly from the object, but are instead exposed (when subscribed to) by the proxy object. The side-effects are still there, but the exposed methods need to be accessed via the proxy object by using the name of the profile in the method call.

Example for use of the added introspection methods (continuing from above examples):


          
  SomeClass.has_published? :AProfile
      #==> true
  SomeClass.get_published_profiles
      #==> [:AProfile]
  SomeClass.get_published_methods(:AProfile)
      #==> [:a_method, :another_method]  sc.published_profiles
      #==> [:AProfile]
  sc.published_methods(:AProfile)
      #==> ["a_method", "another_method"]

          

Sorry to post a lot of code here (below)... maybe Jamis Buck might add what follows to the pubsub project on RAA?

I've created a "pubsub2" reference implementation (derived from pubsub) that implements the abovementioned changes:


          
  # pubsub2 reference implementation (derived from pubsub)
  #
  # Modifications by Corey Wangler:
  #   Object.publish  <=-renamed, was "Object.profile"
  #   Object.publish adds instance var(s) for access to proxy object
  #   Object.get_published_methods  <=-renamed, was "Object.get_profile"
  #   Object#subscribe creates PublishSubscribeProxy object(s) if needed
  #   Object#subscribe changes visibility of proxy access method(s)
  #
  # Additions by Corey Wangler:
  #   PublishSubscribeProxy class
  #   NoPublishedProfileError exception class
  #   Object.has_published?
  #   Object.get_published_profiles
  #   Object#published_profiles
  #   Object#published_methods
  #   raise NoMethodError when requested method is not in the profile
  #   raise NoPublishedProfileError when not a published profile  # Proxy for access to published methods
  class PublishSubscribeProxy
    def initialize( object, profile_sym )
      @object = object
      @profile_sym = profile_sym
      @method_symbols = object.class.get_published_methods(profile_sym)
    end
    def method_missing( meth, *args )
      if @method_symbols.include?(meth)
        @object.method(meth).call(*args)
      else
        raise( NoMethodError, "`" + meth.to_s + 
               "' is not in the subscription list for profile " +
               @profile_sym.to_s )
      end
    end
    def published_methods
      @method_symbols.map {|m| m.to_s}
    end
  end  # Exception for when not a published profile
  class NoPublishedProfileError < NameError
  end  class Object
    # Define a profile that is composed of the given list of methods.
    # Change the visibility of the given list of methods to private.
    def Object.publish( sym, *methods )
      attr_reader sym    #instance var access for a PublishSubscribeProxy
      private sym        #make private until subscribed to
      @profiles ||= Hash.new
      @profiles[ sym ] = methods
      private *methods
      sym
    end    # Determine if a given profile has been published.
    def Object.has_published?( sym )
      !@profiles.nil? and @profiles.has_key?(sym)
    end    # Return a list of published profile symbols, or nil
    def Object.get_published_profiles
      if @profiles.nil?
        nil
      else
        @profiles.keys
      end
    end    # Retrieve list of method symbols associated with the named profile.
    def Object.get_published_methods( sym )
      if !has_published?(sym)
        raise( NoPublishedProfileError, "`" + sym.to_s + 
               "' is not a published profile" )
      end
      @profiles[ sym ]
    end    # Subscribe to the given profiles for the duration of the attached block.
    def subscribe( *profiles, &block )
      need_unsubscribe = Array.new
      begin
        profiles.each do |pro|
          if !public_methods.include? pro.to_s
            if !self.class.has_published?(pro)
              raise( NoPublishedProfileError, "`" + pro.to_s + 
                     "' is not a published profile" )
            end
            if instance_eval("@#{pro.to_s}.nil?")
              instance_eval("@#{pro.to_s}=PublishSubscribeProxy.new(self, #{pro.inspect})")
            end
            instance_eval "class << self; public #{pro.inspect}; end"
            need_unsubscribe.push( pro )
          end
        end
        block.call( self )
      ensure
        need_unsubscribe.each do |pro|
          instance_eval "class << self; private #{pro.inspect}; end"
        end
      end
    end    # Return a list of published profile symbols, or nil
    def published_profiles
      self.class.get_published_profiles
    end    # Return a list of method names for the given profile
    # Note: names, not symbols -- to match other 
    def published_methods( profile )
      self.class.get_published_methods(profile).map {|m| m.to_s}
    end
  end

          

Here's some test code that I ran on it:


          
  class SequenceState
    def get_counter
      @counter
    end    def set_counter( counter )
      @counter = counter
    end    ###profile :StateAccess, :get_counter, :set_counter
    publish :StateAccess, :get_counter, :set_counter
  end  class Sequence
    def initialize( start=0 )
      @counter = start-1
    end    def next
      @counter += 1
    end    def get_state
      SequenceState.new.subscribe( :StateAccess ) do |state|
        ###state.set_counter @counter
        state.StateAccess.set_counter @counter
        return state
      end
    end    def restore_state( state )
      state.subscribe( :StateAccess ) do
        ###@counter = state.get_counter
        @counter = state.StateAccess.get_counter
      end
    end
  end  ### Test basic operation ###
  seq=Sequence.new(33)
  seq.next  #==> 33
  seq.next  #==> 34
  saved_position=seq.get_state
  seq.next  #==> 35
  seq.next  #==> 36
  seq.restore_state(saved_position)  #==> 34
  seq.next  #==> 35
  seq.next  #==> 36  ### Test introspection ###
  saved_position.published_profiles
      #==> [:StateAccess]
  saved_position.published_methods(:StateAccess)
      #==> ["get_counter", "set_counter"]
  saved_position.subscribe(:StateAccess) do |ref|
    ref.published_profiles
    ref.StateAccess.published_methods
  end
      #==> ["get_counter", "set_counter"]  ### Test error handling ###
  saved_position.subscribe(:StateAccess) do
    puts saved_position.StateAccess.get_counter
    saved_position.StateAccess.a_message(12)
  end
      #==>34
      #==>NoMethodError: `a_message' is not in the subscription list
      #                     for profile StateAccess  saved_position.subscribe(:StateAccess, :FreakyAccess) do
    puts saved_position.StateAccess.get_counter
  end
      #==>NoPublishedProfileError: `FreakyAccess' is not a published profile  saved_position.get_counter
      #==>NoMethodError: private method `get_counter' called  saved_position.StateAccess.get_counter
      #==>NoMethodError: private method `StateAccess' called

          

Cheers,
- corey.


Strongly opposed 0
Opposed 1
Neutral 1
In favor 2
Strongly advocate 2
ruby picture

This RCR has been superseded by RCR 198.

ruby picture

Powered by .