-
Notifications
You must be signed in to change notification settings - Fork 4.3k
ARROW-16657: [C++] Support nesting of extension-id-registries #13232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
1606b96
ARROW-16657: [C++] Support nesting of extension-id-registries
rtpsw de6407e
Merge branch 'master' into ARROW-16657
rtpsw cb65dcb
fix merge
rtpsw 4fa003b
lint
rtpsw b0e0112
add tests
rtpsw 16a476f
lint
rtpsw 2592ffa
lint
rtpsw 76e0581
lint
rtpsw ff1a1ad
add virtual dtors
rtpsw e2e7f2b
fix nested test
rtpsw b16b9c0
merge master
rtpsw 36b1296
updated submodule
rtpsw de61104
update testing
rtpsw 77a14b5
add test checks, fix doc typo
rtpsw e0ae1d7
fix doc
rtpsw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -57,7 +57,7 @@ bool ExtensionIdRegistry::IdHashEq::operator()(ExtensionIdRegistry::Id l, | |||||
| // A builder used when creating a Substrait plan from an Arrow execution plan. In | ||||||
| // that situation we do not have a set of anchor values already defined so we keep | ||||||
| // a map of what Ids we have seen. | ||||||
| ExtensionSet::ExtensionSet(ExtensionIdRegistry* registry) : registry_(registry) {} | ||||||
| ExtensionSet::ExtensionSet(const ExtensionIdRegistry* registry) : registry_(registry) {} | ||||||
|
|
||||||
| Status ExtensionSet::CheckHasUri(util::string_view uri) { | ||||||
| auto it = | ||||||
|
|
@@ -96,7 +96,7 @@ Status ExtensionSet::AddUri(Id id) { | |||||
| Result<ExtensionSet> ExtensionSet::Make( | ||||||
| std::unordered_map<uint32_t, util::string_view> uris, | ||||||
| std::unordered_map<uint32_t, Id> type_ids, | ||||||
| std::unordered_map<uint32_t, Id> function_ids, ExtensionIdRegistry* registry) { | ||||||
| std::unordered_map<uint32_t, Id> function_ids, const ExtensionIdRegistry* registry) { | ||||||
| ExtensionSet set; | ||||||
| set.registry_ = registry; | ||||||
|
|
||||||
|
|
@@ -204,150 +204,246 @@ const int* GetIndex(const KeyToIndex& key_to_index, const Key& key) { | |||||
| return &it->second; | ||||||
| } | ||||||
|
|
||||||
| ExtensionIdRegistry* default_extension_id_registry() { | ||||||
| static struct Impl : ExtensionIdRegistry { | ||||||
| Impl() { | ||||||
| struct TypeName { | ||||||
| std::shared_ptr<DataType> type; | ||||||
| util::string_view name; | ||||||
| }; | ||||||
|
|
||||||
| // The type (variation) mappings listed below need to be kept in sync | ||||||
| // with the YAML at substrait/format/extension_types.yaml manually; | ||||||
| // see ARROW-15535. | ||||||
| for (TypeName e : { | ||||||
| TypeName{uint8(), "u8"}, | ||||||
| TypeName{uint16(), "u16"}, | ||||||
| TypeName{uint32(), "u32"}, | ||||||
| TypeName{uint64(), "u64"}, | ||||||
| TypeName{float16(), "fp16"}, | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterType({kArrowExtTypesUri, e.name}, std::move(e.type))); | ||||||
| } | ||||||
|
|
||||||
| for (TypeName e : { | ||||||
| TypeName{null(), "null"}, | ||||||
| TypeName{month_interval(), "interval_month"}, | ||||||
| TypeName{day_time_interval(), "interval_day_milli"}, | ||||||
| TypeName{month_day_nano_interval(), "interval_month_day_nano"}, | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterType({kArrowExtTypesUri, e.name}, std::move(e.type))); | ||||||
| } | ||||||
|
|
||||||
| // TODO: this is just a placeholder right now. We'll need a YAML file for | ||||||
| // all functions (and prototypes) that Arrow provides that are relevant | ||||||
| // for Substrait, and include mappings for all of them here. See | ||||||
| // ARROW-15535. | ||||||
| for (util::string_view name : { | ||||||
| "add", | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterFunction({kArrowExtTypesUri, name}, name.to_string())); | ||||||
| } | ||||||
| } | ||||||
| namespace { | ||||||
|
|
||||||
| struct ExtensionIdRegistryImpl : ExtensionIdRegistry { | ||||||
| virtual ~ExtensionIdRegistryImpl() {} | ||||||
|
|
||||||
| std::vector<util::string_view> Uris() const override { | ||||||
| return {uris_.begin(), uris_.end()}; | ||||||
| std::vector<util::string_view> Uris() const override { | ||||||
| return {uris_.begin(), uris_.end()}; | ||||||
| } | ||||||
|
|
||||||
| util::optional<TypeRecord> GetType(const DataType& type) const override { | ||||||
| if (auto index = GetIndex(type_to_index_, &type)) { | ||||||
| return TypeRecord{type_ids_[*index], types_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| } | ||||||
|
|
||||||
| util::optional<TypeRecord> GetType(const DataType& type) const override { | ||||||
| if (auto index = GetIndex(type_to_index_, &type)) { | ||||||
| return TypeRecord{type_ids_[*index], types_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| util::optional<TypeRecord> GetType(Id id) const override { | ||||||
| if (auto index = GetIndex(id_to_index_, id)) { | ||||||
| return TypeRecord{type_ids_[*index], types_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| } | ||||||
|
|
||||||
| util::optional<TypeRecord> GetType(Id id) const override { | ||||||
| if (auto index = GetIndex(id_to_index_, id)) { | ||||||
| return TypeRecord{type_ids_[*index], types_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| Status CanRegisterType(Id id, const std::shared_ptr<DataType>& type) const override { | ||||||
| if (id_to_index_.find(id) != id_to_index_.end()) { | ||||||
| return Status::Invalid("Type id was already registered"); | ||||||
| } | ||||||
| if (type_to_index_.find(&*type) != type_to_index_.end()) { | ||||||
| return Status::Invalid("Type was already registered"); | ||||||
| } | ||||||
| return Status::OK(); | ||||||
| } | ||||||
|
|
||||||
| Status RegisterType(Id id, std::shared_ptr<DataType> type) override { | ||||||
| DCHECK_EQ(type_ids_.size(), types_.size()); | ||||||
| Status RegisterType(Id id, std::shared_ptr<DataType> type) override { | ||||||
| DCHECK_EQ(type_ids_.size(), types_.size()); | ||||||
|
|
||||||
| Id copied_id{*uris_.emplace(id.uri.to_string()).first, | ||||||
| *names_.emplace(id.name.to_string()).first}; | ||||||
| Id copied_id{*uris_.emplace(id.uri.to_string()).first, | ||||||
| *names_.emplace(id.name.to_string()).first}; | ||||||
|
|
||||||
| auto index = static_cast<int>(type_ids_.size()); | ||||||
| auto index = static_cast<int>(type_ids_.size()); | ||||||
|
|
||||||
| auto it_success = id_to_index_.emplace(copied_id, index); | ||||||
| auto it_success = id_to_index_.emplace(copied_id, index); | ||||||
|
|
||||||
| if (!it_success.second) { | ||||||
| return Status::Invalid("Type id was already registered"); | ||||||
| } | ||||||
| if (!it_success.second) { | ||||||
| return Status::Invalid("Type id was already registered"); | ||||||
| } | ||||||
|
|
||||||
| if (!type_to_index_.emplace(type.get(), index).second) { | ||||||
| id_to_index_.erase(it_success.first); | ||||||
| return Status::Invalid("Type was already registered"); | ||||||
| } | ||||||
| if (!type_to_index_.emplace(type.get(), index).second) { | ||||||
| id_to_index_.erase(it_success.first); | ||||||
| return Status::Invalid("Type was already registered"); | ||||||
| } | ||||||
|
|
||||||
| type_ids_.push_back(copied_id); | ||||||
| types_.push_back(std::move(type)); | ||||||
| return Status::OK(); | ||||||
| } | ||||||
|
|
||||||
| type_ids_.push_back(copied_id); | ||||||
| types_.push_back(std::move(type)); | ||||||
| return Status::OK(); | ||||||
| util::optional<FunctionRecord> GetFunction( | ||||||
| util::string_view arrow_function_name) const override { | ||||||
| if (auto index = GetIndex(function_name_to_index_, arrow_function_name)) { | ||||||
| return FunctionRecord{function_ids_[*index], *function_name_ptrs_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| } | ||||||
|
|
||||||
| util::optional<FunctionRecord> GetFunction(Id id) const override { | ||||||
| if (auto index = GetIndex(function_id_to_index_, id)) { | ||||||
| return FunctionRecord{function_ids_[*index], *function_name_ptrs_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| } | ||||||
|
|
||||||
| Status CanRegisterFunction(Id id, | ||||||
| const std::string& arrow_function_name) const override { | ||||||
| if (function_id_to_index_.find(id) != function_id_to_index_.end()) { | ||||||
| return Status::Invalid("Function id was already registered"); | ||||||
| } | ||||||
| if (function_name_to_index_.find(arrow_function_name) != | ||||||
| function_name_to_index_.end()) { | ||||||
| return Status::Invalid("Function name was already registered"); | ||||||
| } | ||||||
| return Status::OK(); | ||||||
| } | ||||||
|
|
||||||
| Status RegisterFunction(Id id, std::string arrow_function_name) override { | ||||||
| DCHECK_EQ(function_ids_.size(), function_name_ptrs_.size()); | ||||||
|
|
||||||
| Id copied_id{*uris_.emplace(id.uri.to_string()).first, | ||||||
| *names_.emplace(id.name.to_string()).first}; | ||||||
|
|
||||||
| const std::string& copied_function_name{ | ||||||
| *function_names_.emplace(std::move(arrow_function_name)).first}; | ||||||
|
|
||||||
| auto index = static_cast<int>(function_ids_.size()); | ||||||
|
|
||||||
| auto it_success = function_id_to_index_.emplace(copied_id, index); | ||||||
|
|
||||||
| util::optional<FunctionRecord> GetFunction( | ||||||
| util::string_view arrow_function_name) const override { | ||||||
| if (auto index = GetIndex(function_name_to_index_, arrow_function_name)) { | ||||||
| return FunctionRecord{function_ids_[*index], *function_name_ptrs_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| if (!it_success.second) { | ||||||
| return Status::Invalid("Function id was already registered"); | ||||||
| } | ||||||
|
|
||||||
| util::optional<FunctionRecord> GetFunction(Id id) const override { | ||||||
| if (auto index = GetIndex(function_id_to_index_, id)) { | ||||||
| return FunctionRecord{function_ids_[*index], *function_name_ptrs_[*index]}; | ||||||
| } | ||||||
| return {}; | ||||||
| if (!function_name_to_index_.emplace(copied_function_name, index).second) { | ||||||
| function_id_to_index_.erase(it_success.first); | ||||||
| return Status::Invalid("Function name was already registered"); | ||||||
| } | ||||||
|
|
||||||
| Status RegisterFunction(Id id, std::string arrow_function_name) override { | ||||||
| DCHECK_EQ(function_ids_.size(), function_name_ptrs_.size()); | ||||||
| function_name_ptrs_.push_back(&copied_function_name); | ||||||
| function_ids_.push_back(copied_id); | ||||||
| return Status::OK(); | ||||||
| } | ||||||
|
|
||||||
| Id copied_id{*uris_.emplace(id.uri.to_string()).first, | ||||||
| *names_.emplace(id.name.to_string()).first}; | ||||||
| // owning storage of uris, names, (arrow::)function_names, types | ||||||
| // note that storing strings like this is safe since references into an | ||||||
| // unordered_set are not invalidated on insertion | ||||||
| std::unordered_set<std::string> uris_, names_, function_names_; | ||||||
| DataTypeVector types_; | ||||||
|
|
||||||
| // non-owning lookup helpers | ||||||
| std::vector<Id> type_ids_, function_ids_; | ||||||
| std::unordered_map<Id, int, IdHashEq, IdHashEq> id_to_index_; | ||||||
| std::unordered_map<const DataType*, int, TypePtrHashEq, TypePtrHashEq> type_to_index_; | ||||||
|
|
||||||
| std::vector<const std::string*> function_name_ptrs_; | ||||||
| std::unordered_map<Id, int, IdHashEq, IdHashEq> function_id_to_index_; | ||||||
| std::unordered_map<util::string_view, int, ::arrow::internal::StringViewHash> | ||||||
| function_name_to_index_; | ||||||
| }; | ||||||
|
|
||||||
| const std::string& copied_function_name{ | ||||||
| *function_names_.emplace(std::move(arrow_function_name)).first}; | ||||||
| struct NestedExtensionIdRegistryImpl : ExtensionIdRegistryImpl { | ||||||
| explicit NestedExtensionIdRegistryImpl(const ExtensionIdRegistry* parent) | ||||||
| : parent_(parent) {} | ||||||
|
|
||||||
| auto index = static_cast<int>(function_ids_.size()); | ||||||
| virtual ~NestedExtensionIdRegistryImpl() {} | ||||||
|
|
||||||
| auto it_success = function_id_to_index_.emplace(copied_id, index); | ||||||
| std::vector<util::string_view> Uris() const override { | ||||||
| std::vector<util::string_view> uris = parent_->Uris(); | ||||||
| std::unordered_set<util::string_view> uri_set; | ||||||
| uri_set.insert(uris.begin(), uris.end()); | ||||||
| uri_set.insert(uris_.begin(), uris_.end()); | ||||||
| return std::vector<util::string_view>(uris); | ||||||
| } | ||||||
|
|
||||||
| if (!it_success.second) { | ||||||
| return Status::Invalid("Function id was already registered"); | ||||||
| } | ||||||
| util::optional<TypeRecord> GetType(const DataType& type) const override { | ||||||
| auto type_opt = ExtensionIdRegistryImpl::GetType(type); | ||||||
| if (type_opt) { | ||||||
| return type_opt; | ||||||
| } | ||||||
| return parent_->GetType(type); | ||||||
| } | ||||||
|
|
||||||
| util::optional<TypeRecord> GetType(Id id) const override { | ||||||
| auto type_opt = ExtensionIdRegistryImpl::GetType(id); | ||||||
| if (type_opt) { | ||||||
| return type_opt; | ||||||
| } | ||||||
| return parent_->GetType(id); | ||||||
| } | ||||||
|
|
||||||
| if (!function_name_to_index_.emplace(copied_function_name, index).second) { | ||||||
| function_id_to_index_.erase(it_success.first); | ||||||
| return Status::Invalid("Function name was already registered"); | ||||||
| } | ||||||
| Status RegisterType(Id id, std::shared_ptr<DataType> type) override { | ||||||
| return parent_->CanRegisterType(id, type) & | ||||||
| ExtensionIdRegistryImpl::RegisterType(id, type); | ||||||
| } | ||||||
|
|
||||||
| function_name_ptrs_.push_back(&copied_function_name); | ||||||
| function_ids_.push_back(copied_id); | ||||||
| return Status::OK(); | ||||||
| util::optional<FunctionRecord> GetFunction( | ||||||
| util::string_view arrow_function_name) const override { | ||||||
| auto func_opt = ExtensionIdRegistryImpl::GetFunction(arrow_function_name); | ||||||
| if (func_opt) { | ||||||
| return func_opt; | ||||||
| } | ||||||
| return parent_->GetFunction(arrow_function_name); | ||||||
| } | ||||||
|
|
||||||
| // owning storage of uris, names, (arrow::)function_names, types | ||||||
| // note that storing strings like this is safe since references into an | ||||||
| // unordered_set are not invalidated on insertion | ||||||
| std::unordered_set<std::string> uris_, names_, function_names_; | ||||||
| DataTypeVector types_; | ||||||
| util::optional<FunctionRecord> GetFunction(Id id) const override { | ||||||
| auto func_opt = ExtensionIdRegistryImpl::GetFunction(id); | ||||||
| if (func_opt) { | ||||||
| return func_opt; | ||||||
| } | ||||||
| return parent_->GetFunction(id); | ||||||
| } | ||||||
|
|
||||||
| // non-owning lookup helpers | ||||||
| std::vector<Id> type_ids_, function_ids_; | ||||||
| std::unordered_map<Id, int, IdHashEq, IdHashEq> id_to_index_; | ||||||
| std::unordered_map<const DataType*, int, TypePtrHashEq, TypePtrHashEq> type_to_index_; | ||||||
| Status RegisterFunction(Id id, std::string arrow_function_name) override { | ||||||
| return parent_->CanRegisterFunction(id, arrow_function_name) & | ||||||
| ExtensionIdRegistryImpl::RegisterFunction(id, arrow_function_name); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not make this change per above discussion. |
||||||
| } | ||||||
|
|
||||||
| std::vector<const std::string*> function_name_ptrs_; | ||||||
| std::unordered_map<Id, int, IdHashEq, IdHashEq> function_id_to_index_; | ||||||
| std::unordered_map<util::string_view, int, ::arrow::internal::StringViewHash> | ||||||
| function_name_to_index_; | ||||||
| } impl_; | ||||||
| const ExtensionIdRegistry* parent_; | ||||||
| }; | ||||||
|
|
||||||
| struct DefaultExtensionIdRegistry : ExtensionIdRegistryImpl { | ||||||
| DefaultExtensionIdRegistry() { | ||||||
| struct TypeName { | ||||||
| std::shared_ptr<DataType> type; | ||||||
| util::string_view name; | ||||||
| }; | ||||||
|
|
||||||
| // The type (variation) mappings listed below need to be kept in sync | ||||||
| // with the YAML at substrait/format/extension_types.yaml manually; | ||||||
| // see ARROW-15535. | ||||||
| for (TypeName e : { | ||||||
| TypeName{uint8(), "u8"}, | ||||||
| TypeName{uint16(), "u16"}, | ||||||
| TypeName{uint32(), "u32"}, | ||||||
| TypeName{uint64(), "u64"}, | ||||||
| TypeName{float16(), "fp16"}, | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterType({kArrowExtTypesUri, e.name}, std::move(e.type))); | ||||||
| } | ||||||
|
|
||||||
| for (TypeName e : { | ||||||
| TypeName{null(), "null"}, | ||||||
| TypeName{month_interval(), "interval_month"}, | ||||||
| TypeName{day_time_interval(), "interval_day_milli"}, | ||||||
| TypeName{month_day_nano_interval(), "interval_month_day_nano"}, | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterType({kArrowExtTypesUri, e.name}, std::move(e.type))); | ||||||
| } | ||||||
|
|
||||||
| // TODO: this is just a placeholder right now. We'll need a YAML file for | ||||||
| // all functions (and prototypes) that Arrow provides that are relevant | ||||||
| // for Substrait, and include mappings for all of them here. See | ||||||
| // ARROW-15535. | ||||||
| for (util::string_view name : { | ||||||
| "add", | ||||||
| }) { | ||||||
| DCHECK_OK(RegisterFunction({kArrowExtTypesUri, name}, name.to_string())); | ||||||
| } | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| } // namespace | ||||||
|
|
||||||
| ExtensionIdRegistry* default_extension_id_registry() { | ||||||
| static DefaultExtensionIdRegistry impl_; | ||||||
| return &impl_; | ||||||
| } | ||||||
|
|
||||||
| std::shared_ptr<ExtensionIdRegistry> nested_extension_id_registry( | ||||||
| const ExtensionIdRegistry* parent) { | ||||||
| return std::make_shared<NestedExtensionIdRegistryImpl>(parent); | ||||||
| } | ||||||
|
|
||||||
| } // namespace engine | ||||||
| } // namespace arrow | ||||||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested this change but it causes a segmentation fault with the following gdb output:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's odd. Do you know what compiler you are using?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not convinced there is something unexpected here. Presumably, the use of
std::moveon astd::shared_ptr<DataType>invalidates the caller's copy, which leads to a segmentation fault when the caller tries to access it. AFAICS, passing or returning ashared_ptrby value is normal in Arrow, so it should be fine here too.To your question, I think my build is using gcc, for which I have version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll be the first to admit that there is a good possibility I'm just plain wrong or missing something 😆 so I apologize for the trouble in advance. However, I think you are correct, that a
std::moveis not allowed here. After doing some research though, I think your explanation is slightly off.The function argument is (correctly, IMO) defined as
RegisterType(Id id, std::shared_ptr<DataType> type)and notRegisterType(Id id, const std::shared_ptr<DataType>& type)so this function is explicitly requesting its own copy of theshared_ptr.So nothing we do here should be capable of invalidating the caller's copy.
Instead I think this is a consequence of using
&as the status coalescing operator and not&&. I believe it was you that pointed out a while back that this is a little odd. Perhaps it is not just odd but actually semantically incorrect as well. It turns out that&&defines a "sequence point" (https://en.cppreference.com/w/cpp/language/eval_order) that prevents the compiler from reordering statements.What I think is happening is the compiler is choosing to reorder the call to
ExtensionIdRegistryImpl::RegisterType(id, type)before the call toparent_->CanRegisterType(id, type). When I first made this comment I thought that sort of reordering was illegal and thus a bug. However, after reading about sequence points, it seems that reordering is perfectly legal and so usingstd::moveis indeed incorrect here (although this is more motivation to perhaps change the operator to&&but that does not need to be a concern for this PR).So, thank you for bearing with me, I consider this comment resolved. I'll follow up on the
&/&&in a separate JIRA to see how others feel about it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, great explanation!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For future reference, here is the discussion about
&/&&you are referring to.