Feature #16150
openAdd a way to request a frozen string from to_s
Added by headius (Charles Nutter) about 7 years ago. Updated over 4 years ago.
Description
Much of the time when a user calls to_s, they are just looking for a simple string representation to display or to interpolate into another string. In my brief exploration, the result of to_s is rarely mutated directly.
It seems that we could save a lot of objects by providing a way to explicitly request a frozen string.
For purposes of discussion I will call this to_frozen_string, which is a terrible name.
This would reduce string allocations dramatically when applied to many common to_s calls:
- Symbol#to_frozen_string could always return the same cached String representation. This method is heavily used by almost all Ruby code that intermingles Symbols and Strings.
- nil, true, false, and any other singleton values in the system could similarly cache and return the same String object.
- The strings coming from core types could also be in the fstring cache and deduplicated as a result.
- User-provided to_s implementations could opt-in to caching and returning the same frozen String object when the author knows that the result will always be the same.
A few ideas for what to call this:
to_fstringorfstringreflects internal the "fstring" cache but is perhaps not obvious for most users.to_s(frozen: true)is clean but there will be many cases when the kwargs hash doesn't get eliminated, making matters worse.def to_s(frozen = false)would be mostly free but may not be compatible with existing to_s params (likeInteger#to_s(radix)
This idea was inspired by @schneems's talk at RubyConf Thailand, where he showed significant overhead in ActiveRecord from Symbol#to_s allocation.
Updated by schneems (Richard Schneeman) about 7 years ago
Actions
#1
[ruby-core:94816]
Thank you for writing up the proposal. This would certainly helped my optimization case. Here’s the PR I referenced in my talk where I had to work around the allocations from calling to_s on a symbol: https://github.com/rails/rails/pull/34197
Updated by headius (Charles Nutter) about 7 years ago
Actions
#2
[ruby-core:94819]
Another idea: #to_z
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#3
[ruby-core:94820]
Another possibility: make/let #to_s methods of core types return frozen strings.
The general contract of to_s would become: "return a String representing the object, which might be frozen. Use .to_s.dup if you need a mutable string".
That would be less compatible but would have the advantage of existing code already being able to use those savings without changes and complications.
My impression is to_s does already not guarantee to return a new String.
For example, String#to_s returns itself and mutating sth.to_s is therefore not safe if sth can be a String.
Also, to_s definitions for user classes seem likely to already return a frozen String simply because they might use a String literal (interpolated or not) for it and frozen-string-literal: true.
Updated by shevegen (Robert A. Heiler) about 7 years ago
Actions
#4
[ruby-core:94826]
Just a short comment - I originally had to write more, but I think my statement would
be too diluted.
Also, to_s definitions for user classes seem likely to already return
a frozen String simply because they might use a String literal
(interpolated or not) for it and frozen-string-literal: true.
IMO, it currently can not be assumed to hold true because there may also
be frozen-string-literal: false settings, and frozen strings are not yet
the default nor will be in ruby 3.0, so I would not assume this the case
right now. This may change in the future, but right now I think it would
confuse people if to_s and to_str do NOT return a string or string-like
object; and if a string is returned, it may be frozen, despite frozen-string
literals set to false. The latter would qualify as a bug in my opinion.
I have no specific opinion about the suggested APIs, but the names are a
bit strange; or a bit cumbersome. "fstring" reads a bit like "formatted
string".
Oddly enough, I think #to_z is a bit better simply because it is shorter,
but people may still ask why the "z" is there. I guess to_f was not
offered as suggestion for frozen, due to it already meaning to a float
representation. :)
But in general, I don't really have any pro or con opinion either way. (Most
of the suggestion seems to be more inspired about speed/efficiency and less
about specific end-user use.)
Updated by Dan0042 (Daniel DeLorme) about 7 years ago
Actions
#5
[ruby-core:94828]
Great idea.
This definitely has to be a new method because using to_s(frozen: true) would require to know if the object supports the frozen keyword for its to_s method.
As a method, the default implementation would be to_s(*a,**o).freeze and interpolations can automatically use that. Then it's just a matter of changing various classes (such as Symbol) so that to_frozen_string is the master implementation and to_s becomes to_frozen_string.dup
Maybe it's possible for the parser to lexically convert the expressions -expr.to_s and to use << expr.to_sto_frozen_string
Naming is hard. Short is good because we want to encourage using that method as much as possible. to_fstr, to_s!, to_fs, to_fzs
Updated by headius (Charles Nutter) about 7 years ago
Actions
#6
[ruby-core:94835]
For example, String#to_s returns itself and mutating sth.to_s is therefore not safe if sth can be a String.
This is a very good point. I'd love to see all immutable core types start returning frozen string but I didn't want to push too hard. It would mean immediate benefits without any changes in existing apps and libraries.
Naming is hard. Short is good because we want to encourage using that method as much as possible. to_fstr, to_s!, to_fs, to_fzs
Oh, to_s! is pretty good.
Updated by Dan0042 (Daniel DeLorme) about 7 years ago
Actions
#7
[ruby-core:94836]
For example, String#to_s returns itself and mutating sth.to_s is therefore not safe if sth can be a String.
String#to_s is of course a special case. But that means if a string is currently not frozen, to_frozen_string would have to do dup.freeze... doesn't that ruin the entire point of this proposal, which is about efficiency? It would mean "a #{str}" creates a duplicate of str because interpolation uses the supposedly efficient to_frozen_string. So maybe this proposal really should be called to_efficient_string, which doesn't guarantee a frozen string but rather attempts to minimize allocations. In that context, to_s! makes even more sense because you don't know what kind of string you'll get.
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#8
[ruby-core:94839]
How about specifically making Symbol#to_s return a frozen String?
I tried this trivial patch:
diff --git a/string.c b/string.c
index 05ce0ed8d6..1a0fa48a6a 100644
--- a/string.c
+++ b/string.c
@@ -10866,7 +10866,9 @@ sym_inspect(VALUE sym)
VALUE
rb_sym_to_s(VALUE sym)
{
- return str_new_shared(rb_cString, rb_sym2str(sym));
+ VALUE str = str_new_shared(rb_cString, rb_sym2str(sym));
+ OBJ_FREEZE(str);
+ return str;
}
And there are 0 test-spec failures and 0 test-all failures.
So, let's make Symbol#to_s frozen?
I think in general it makes a lot of sense that immutable classes return a frozen String for #to_s.
Making #to_s return a frozen String for mutable core classes like Array or Hash is likely much less interesting, as one cannot cache a single String instance but must check the object's contents everytime (and the #to_s result depends on other objects' #to_s which makes it a lot more complicated).
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#9
[ruby-core:94840]
I also tried this version which always returns the same String for a given Symbol:
diff --git a/string.c b/string.c
index 05ce0ed8d6..f306c905bc 100644
--- a/string.c
+++ b/string.c
@@ -10866,7 +10866,7 @@ sym_inspect(VALUE sym)
VALUE
rb_sym_to_s(VALUE sym)
{
- return str_new_shared(rb_cString, rb_sym2str(sym));
+ return rb_sym2str(sym);
}
And that passes both test-spec and test-all, except this test which probably needs to be adapted:
[5/8] Test_StringCapacity#test_capacity_shared = 0.00 s
1) Failure:
Test_StringCapacity#test_capacity_shared [/home/eregon/code/ruby/test/-ext-/string/test_capacity.rb:17]:
<0> expected but was
<26>.
I'll make a PR.
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#10
[ruby-core:94841]
Updated by ashmaroli (Ashwin Maroli) about 7 years ago
Actions
#11
[ruby-core:94842]
Updated by sawa (Tsuyoshi Sawada) about 7 years ago
Actions
#12
[ruby-core:94843]
For methods that convert object to a string, we already have three:
to_sto_strinspect
Adding yet another method (or an option for an existing method) would make the picture too complicated. It might be a good time to reconsider the division of labor between the existing three methods.
It is suspicious whether the distinction between to_s vs. to_str, to_h vs. to_hash, to_a vs. to_ary, or to_i vs. to_int etc. is nowadays used in the way it was intended at the beginning. Note that, while the single splat * on an object implicitly calls the explicit to_a, the double splat ** implicitly calls the implicit to_hash. Also note that there is no explicit counterpart for the implicit method to_proc, which is used as in Symbol#to_proc, Method#to_proc, Hash#to_proc, where the (missing) explicit counterpart should be used.
Since the implicit vs. explicit distinction is messed up, but we are not having a big problem with it, there is not much motivation to try to maintain such distinction.
Personally, I am not that sure why we need to_str just to show that an object is string-like. For example, we could have the argument of Array#join undergo to_s (with a fallback to to_str) instead of (just) to_str. I don't see any problem with that.
As an example of rearranging the division of labour, one method out of the three mentioned above could be used for creating a new string instance, and another method for referencing a cached representation.
But that is just an example. There may be other ways that can make more sense. The point is, we can perhaps make use of the existing methods rather than adding a new one.
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#13
[ruby-core:94847]
ashmaroli (Ashwin Maroli) wrote:
Similar to
Symbol#to_s, evennil.to_sallocates a new string.
Right, as mentioned in the description, nil, true and false#to_s could all be frozen and cached the same way as for Symbol#to_s.
Although I'd think calling #to_s on those is already less frequent and performance-sensitive than Symbol#to_s.
Are there other immutable types where it would be worthwhile to cache the #to_s result?
I can't find any now, at least it doesn't seem worthwhile for numeric types, and most other core types are not immutable.
Updated by Dan0042 (Daniel DeLorme) about 7 years ago
Actions
#14
[ruby-core:94853]
So, let's make Symbol#to_s frozen?
I think in general it makes a lot of sense that immutable classes return a frozen String for #to_s.
That makes sense, and I agree that's the cleanest solution. There's really nothing in the contract that says to_s is supposed to return a non-frozen string.
But I can't agree to a backward-incompatible change without a proper deprecation period.
So I went and added Feature #16153 for a way to allow a phase-in period for frozen Symbol#to_s.
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#15
[ruby-core:94884]
Dan0042 (Daniel DeLorme) wrote:
But I can't agree to a backward-incompatible change without a proper deprecation period.
So I went and added Feature #16153 for a way to allow a phase-in period for frozenSymbol#to_s.
I'm unsure if such troubles are necessary, because the practical incompatibility might be extremely low, such as illustrated by in-repository specs and tests.
Do we have an example of real code breaking because of Symbol#to_s returning a frozen String?
Could someone try my PR on their app and see if it breaks anything?
Updated by Eregon (Benoit Daloze) about 7 years ago
Actions
#16
[ruby-core:94885]
I tried running ActiveSupport tests from Rails master on Ruby 2.6.2 + my PR, and found that one change is needed:
Error:
ConfigurableActiveSupport#test_configuration_accessors_are_not_available_on_instance:
FrozenError: can't modify frozen String
/home/eregon/code/rails/activesupport/lib/active_support/ordered_options.rb:43:in `chomp!'
/home/eregon/code/rails/activesupport/lib/active_support/ordered_options.rb:43:in `method_missing'
...
https://github.com/rails/rails/blob/7af44f49dbf221c3b7f0b0d476913a74b6a1d0e4/activesupport/lib/active_support/ordered_options.rb#L42-L43
A simple fix is to use name_string = +name.to_s, or to refactor to use start_with? + name_string[0..-2] instead of chomp!.
Then all ActiveSupport tests pass.
Updated by byroot (Jean Boussier) about 7 years ago
Actions
#17
[ruby-core:94893]
@Eregon (Benoit Daloze), for context Matz recently refused a very similar change https://bugs.ruby-lang.org/issues/15836
Updated by matz (Yukihiro Matsumoto) about 7 years ago
Actions
#18
[ruby-core:94975]
For frozen Symbol#to_s, I see a clear benefit. But I worry a little bit for incompatibility.
So how about making an experiment by the next preview(2) to see how big the incompatibility is?
String#to_s etc. is a different story. we need to discuss further.
Matz.
Updated by byroot (Jean Boussier) about 7 years ago
Actions
#19
[ruby-core:95089]
@matz (Yukihiro Matsumoto) Great to hear.
I get you are worried about String#to_s, but what about others that have been mentioned here, namely:
NilClass#to_sTrueClass/FalseClass#to_sModule#name