Project

General

Profile

Actions

Feature #19024

closed

Proposal: Import Modules

Added by shioyama (Chris Salzberg) over 1 year ago. Updated about 1 year ago.

Status:
Closed
Assignee:
-
Target version:
-
[ruby-core:110097]

Description

There is no general way in Ruby to load code outside of the globally-shared namespace. This makes it hard to isolate components of an application from each other and from the application itself, leading to complicated relationships that can become intractable as applications grow in size.

The growing popularity of a gem like Packwerk, which provides a new concept of "package" to enforce boundaries statically in CI, is evidence that this is a real problem. But introducing a new packaging concept and CI step is at best only a partial solution, with downsides: it adds complexity and cognitive overhead that wouldn't be necessary if Ruby provided better packaging itself (as Matz has suggested it should).

There is one limited way in Ruby currently to load code without polluting the global namespace: load with the wrap parameter, which as of https://bugs.ruby-lang.org/issues/6210 can now be a module. However, this option does not apply transitively to require calls within the loaded file, so its usefulness is limited.

My proposal here is to enable module imports by doing the following:

  1. apply the wrap module namespace transitively to requires inside the loaded code, including native extensions (or provide a new flag or method that would do this),
  2. make the wrap module the toplevel context for code loaded under it, so ::Foo resolves to <top_wrapper>::Foo in loaded code (or, again, provide a new flag or method that would do this). Also make this apply when code under the wrapper module is called outside of the load process (when top_wrapper is no longer set) — this may be quite hard to do.
  3. resolve name on anonymous modules under the wrapped module to their names without the top wrapper module, so <top_wrapper>::Foo.name evaluates to "Foo". There may be other ways to handle this problem, but a gem like Rails uses name to resolve filenames and fails when anonymous modules return something like #<Module: ...>::ActiveRecord instead of just ActiveRecord.

I have roughly implemented these three things in this patch. This implementation is incomplete (it does not cover the last highlighted part of 2) but provides enough of a basis to implement an import method, which I have done in a gem called Im.

Im provides an import method which can be used to import gem code under a namespace:

require "im"
extend Im

active_model = import "active_model"
#=> <#Im::Import root: active_model>

ActiveModel
#=> NameError

active_model::ActiveModel
#=> ActiveModel

active_record = import "active_record"
#=> <#Im::Import root: active_record>

# Constants defined in the same file under different imports point to the same objects
active_record::ActiveModel == active_model::ActiveModel
#=> true

With the constants all loaded under an anonymous namespace, any code importing the gem can name constants however it likes:

class Post < active_record::ActiveRecord::Base
end

AR = active_record::ActiveRecord

Post.superclass
#=> AR::Base

Note that this enables the importer to completely determine the naming for every constant it imports. So gems can opt to hide their dependencies by "anchoring" them inside their own namespace, like this:

# in lib/my_gem.rb
module MyGem
  dep = import "my_gem_dependency"

  # my_gem_dependency is "anchored" under the MyGem namespace, so not exposed to users
  # of the gem unless they also require it.
  MyGemDependency = dep

  #...
end

There are a couple important implementation decisions in the gem:

  1. Only load code once. When the same file is imported again (either directly or transitively), "copy" constants from previously imported namespace to the new namespace using a registry which maps which namespace (import) was used to load which file (as shown above with activerecord/activemodel). This is necessary to ensure that different imports can "see" shared files. A similar registry is used to track autoloads so that they work correctly when used from imported code.
  2. Toplevel core types (NilClass, TrueClass, FalseClass, String, etc) are "aliased" to constants under each import module to make them available. Thus there can be side-effects of importing code, but this allows a gem like Rails to monkeypatch core classes which it needs to do for it to work.
  3. Object.const_missing is patched to check the caller location and resolve to the constant defined under an import, if there is an import defined for that file.

To be clear: I think 1) should be implemented in Ruby, but not 2) and 3). The last one (Object.const_missing) is a hack to support the case where a toplevel constant is referenced from a method called in imported code (at which point the top_wrapper is not active.)

I know this is a big proposal, and there are strong opinions held. I would really appreciate constructive feedback on this general idea.

Notes from September's Developers Meeting: https://github.com/ruby/dev-meeting-log/blob/master/DevMeeting-2022-09-22.md#feature-10320-require-into-module-shioyama

See also similar discussion in: https://bugs.ruby-lang.org/issues/10320


Related issues 3 (3 open0 closed)

Related to Ruby master - Feature #10320: require into moduleOpenActions
Related to Ruby master - Feature #19277: Project-scoped refinementsOpenActions
Related to Ruby master - Feature #19744: Namespace on readOpenActions
Actions

Also available in: Atom PDF

Like1
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like1Like0Like0Like0Like0Like0Like0Like0Like0