Objective-C Value Objects: Code Generation

In my previous post, I talked about using Value Objects in Objective-C projects. I gave an example of a Ruby DSL that could be used to specify the object’s properties so the code could be generated.

In this post, I’ll go through some Ruby code that can turn that DSL into an Objective-C header and implementation file.

The Domain-Specific Language

The DSL will provide a way to specify the name of the class, as well as each of the properties for the class. Here’s an example:


model "Person" do
  property "name", type: "NSString *"
  property "age", type: "NSNumber *"
  property "height", type: "NSNumber *"
  property "weight", type: "NSNumber *"
  property "alive", type: "BOOL", default: "YES", getter: "isAlive"
end

The DSL is very simple, providing only four keywords: model, property, headers, and enums. Everything else is conveyed in the arguments to the property method.

The property method takes two arguments. The first is the name of the property, and the second is a hash of additional arguments, with the only required key being the :type. In addition to the :type, you can also specify :default, :getter, and :comment.

The enums and headers methods both take strings that will be copied directly into the generated header file. For example:


headers <<-EOS
#import "SomeOtherFile.h"
EOS

model "Person" do
  property "name", type: "NSString *"
end

The Context

We’ll start with a class that provides the keywords used in the DSL, called Context.


class Context
  attr_reader :properties,
    :class_name,
    :headers_str,
    :enums_str

  def initialize
    @properties = []
  end

  def model(model_class_name, &block)
    @class_name = model_class_name
    instance_eval(&block)
  end

  def property(name, args = {})
    @properties << args.merge(name: name)
  end

  def enums(str)
    @enums_str = str
  end

  def headers(str)
    @headers_str = str
  end
end

The only thing of any interest here is in the model method. It takes a block that is instance_eval’d so that the Context object’s property method can be called from inside the block.

The Model Definition

Next, we’ll define a class called ModelDefinition that will use the Context along with some ERB templates to provide the contents of the generated files.

This one is a little longer, so I’ll break it up into chunks.


class ModelDefinition
  def initialize(text)
    @context = Context.new
    @context.instance_eval(text)
  end

  def header
    erb_template = ERB.new(header_template, nil, '-')
    erb_template.result(binding)
  end

  def implementation
    erb_template = ERB.new(implementation_template, nil, '-')
    erb_template.result(binding)
  end

  def class_name
    context.class_name
  end

  private

  attr_reader :context

  .
  .
  .

In the initializer, the passed in text, which is expected to be the model definition using the DSL, is instance_eval’d against an a Context object. From that point forward, the context knows all of the details of the model being generated.

Next, we’ll define a couple of helper methods that can be used by the ERB templates to generate the Objective-C @property lines in the header file, as well as some reasonable defaults that can be used for properties of some standard types.


  def property_definition(readonly, args)
    parts = ["@property"]

    readonly_string = readonly ? ", readonly" : ""

    property_options = ["nonatomic"]

    if args[:type].include?("*")
      property_options << (args[:type] =~ /^NS/ ? "copy" : "strong")
    end

    if readonly
      property_options << "readonly"
    end

    if args[:getter]
      property_options << "getter=#{args[:getter]}"
    end

    parts << "(#{property_options.join(", ")})"

    parts << args[:type]
    parts << args[:name]

    line = parts.join(" ").gsub("* ", "*")

    "#{line};#{args[:comment] ? " // #{args[:comment]}" : nil}"
  end

  def default_value(args)
    if args[:default]
      args[:default]
    else 
      case args[:type]
      when "BOOL"
        "NO"
      when /NSArray/
        "@[]"
      when /NSDictionary/
        "@{}"
      when /\*/
        "nil"
      else
        "0"
      end
    end
  end
  
  .
  .
  .

Next is a method that returns the ERB template for the header (.h) file.


  def header_template
    template = <
<%= context.headers_str %>
<%- end -%>

<%- if context.enums_str -%>
<%= context.enums_str %>
<%- end -%>

@interface <%= context.class_name %>Builder : MTLModel
<% context.properties.each do |prop| -%>
<%= property_definition(false, prop) %>
<% end -%>
@end

@interface <%= context.class_name %> : MTLModel
<% context.properties.each do |prop| -%>
<%= property_definition(true, prop) %>
<% end -%>

- (instancetype)init;
- (instancetype)initWithBuilder:(<%= context.class_name %>Builder *)builder;
+ (instancetype)makeWithBuilder:(void (^)(<%= context.class_name %>Builder *))updateBlock;
- (instancetype)update:(void (^)(<%= context.class_name %>Builder *))updateBlock;
@end
EOS
  end
  .
  .
  .

And finally, a method that returns the ERB template for the implementation (.m) file.


  def implementation_template
    template = <Builder
- (instancetype)init {
    if (self = [super init]) {
      <%- context.properties.each do |prop| -%>
        _<%= prop[:name] %> = <%= default_value(prop) %>;
      <%- end -%>
    }
    return self;
}
@end

@implementation <%= context.class_name %>

- (instancetype)initWithBuilder:(<%= context.class_name %>Builder *)builder {
    if (self = [super init]) {
      <%- context.properties.each do |prop| -%>
        _<%= prop[:name] %> = builder.<%= prop[:name] %>;
      <%- end -%>
    }
    return self;
}

- (<%= class_name %>Builder *)makeBuilder {
    <%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
    <%- context.properties.each do |prop| -%>
    builder.<%= prop[:name] %> = _<%= prop[:name] %>;
    <%- end -%>
    return builder;
}

- (instancetype)init {
    <%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
    return [self initWithBuilder:builder];
}

+ (instancetype)makeWithBuilder:(void (^)(<%= context.class_name %>Builder *))updateBlock {
    <%= context.class_name %>Builder *builder = [<%= context.class_name %>Builder new];
    updateBlock(builder);
    return [[<%= context.class_name %> alloc] initWithBuilder:builder];
}

- (instancetype)update:(void (^)(<%= context.class_name %>Builder *))updateBlock {
    <%= context.class_name %>Builder *builder = [self makeBuilder];
    updateBlock(builder);
    return [[<%= context.class_name %> alloc] initWithBuilder:builder];
}
@end
EOS
  end
end

The Script

Lastly, we need a script that makes use of the two classes we defined above:


def replace_file(path, content)
  File.open(path, "w") do |file|
    file.write(content)
  end
end

ARGV.each do |filename|
  model_definition = ModelDefinition.new(File.read(filename))

  replace_file("output/#{model_definition.class_name}.h", 
               model_definition.header)

  replace_file("output/#{model_definition.class_name}.m", 
               model_definition.implementation)
end

This script will iterate over each filename passed into it. It reads the contents of each file and generates a header/implementation file for each one, writing the generated files into an output directory.

You can see the entire script here.

More Than Just Value Objects

Little one-off DSLs like this can be very useful for all sorts of code generation purposes—from generating production code to populating test data for an acceptance test.

Here, I’ve shown how easy it is to put together a script for generating Objective-C classes for immutable value objects. Use it as a starting point, expand it, or convert it to generate your own code for your own Objective-C project, or for an entirely different programming language.

Conversation
  • panshimin says:

    hi,how use the immutables.rb?
    can you give a demo?

    • Patrick Bacon Patrick Bacon says:

      Yes, you run the script passing the names of the files to process. For example, to run it against a single file you’d run:

      
      ruby immutables.rb person.rb
      

      Or to process a whole directory of files:

      
      ruby immutables.rb immutables/*
      
  • panshimin says:

    ruby immutables.rb userModel.rb
    immutables.rb:191:in `initialize’: No such file or directory @ rb_sysopen – output/Person.h (Errno::ENOENT)
    from immutables.rb:191:in `open’
    from immutables.rb:191:in `replace_file’
    from immutables.rb:199:in `block in ‘
    from immutables.rb:196:in `each’
    from immutables.rb:196:in `’

    userModel.rb
    model “Person” do
    property “name”, type: “NSString *”
    property “age”, type: “NSNumber *”
    property “height”, type: “NSNumber *”
    property “weight”, type: “NSNumber *”
    property “alive”, type: “BOOL”, default: “YES”, getter: “isAlive”
    end

    • Patrick Bacon Patrick Bacon says:

      I think you just need to create a directory called “output” for it to put the files in. Or you could update the script to write out the files to the current directory, which is probably what I should have done in the first place.

  • Comments are closed.