This is the second iteration of the Natural language text fast tokenizer code review. Special thanks goes to G. Sliepen and Toby Speight who conducted the first review.
Functional specification
Implement a function for fast tokenization of text in char[] buffer handling some natural language specifics below:
- Consider ‘ ‘ (space) as a delimiter, keeping a way to extends the list of delimiters later.
- Extract stable collocations like “i.e.”, “etc.”, “…” as a single lexem.
- In case word contains characters like ‘-‘ and ‘’’ (examples: semi-column, half-, cat’s) return the whole construct as a whole lexem. (3.1) Otherwise, split word and return parts as separate lexems and each non-alphanumeric "inword" symbols as separate lexems.
- Return sequences of numbers (integers without signs) as a single lexem.
Performance is critical, since the amount of data is huge. The function should be thread-safe.
Note: The defect in specification was found by Matthieu M in question for first code review when I already posted current Rev.2.: any "inword" non-alphanumeric character except described in item 3 above returns as separate lexems which contradicted original functional specification. Since I still in two minds about behaviour I want here, the item 3.1 has been added which describes how it works now. If you change the code and removing 3.1 to keep lexems with internal non-alphanumeric as a whole (like "a|b") will simplify the code, please feel free to fix on the fly. I will fix this in Rev3.
Answers/comments on the items in the first Code Review iteration
- I am not sure we need
tokenize
function since we can just useTokenRange(sample)
; why do we need a wrapper here? Any purpose or risks without it? - Thank you for the explanation on the full
std::ranges
approach. Taking into account that I will need to use the tokenized with the execution policies which are still missing instd::ranges
, I will stay with current implementation so far; considering to make view just for learning ranges/views/projections. - The question about regex library usage is still on the table. Isn’t regex the tool intended exactly for these purposes? Shouldn’t the code with regex be shorter in times? The only concern is performance, which I mentioned in the first post.
- I decided not to inherit
Iterator
tostd::iterator
because of KISS principle; so far I don’t see any benefits from it. When I see how this could be really used, I will rework this as a separate exercise.
Changelog
- The code reworked to ranges approach with
TokenRange
. - To avoid offset recalculation for operator++, the mutable
next
field introduced. - Recent C++ features used including switching to
std::isdigit
andstd::isalpha
.
Concerns
- The main issue is this
Iterator::end()
. In the tutorials they say that the best approach is to transparently pass theend()
iterator of underlying data, but since here I can’t returndata.end()
, the empty range is returned and I am still in two minds ifend()
is the only case when empty range could occur, so this could lead to false-positive check toend()
if I am correct. Please, comment. - Mutable
next
to pass the offset fromoperator*
tooperator++
is not the best solution here, but I don’t see anything better. Any suggestions? - Still don’t like the code in the inner while loop in the
TokenRange::Iterator::operator*
. It is hard to grasp and prove to be correct, but don’t see any way to simplify it. - Still not sure if class
Iterator
is the best place for thesedilimiters
,stable_lexems
andinword_lexems
; I want them to be configurable later, but still in two minds if they should belong toTokenRange
or toIterator
, since formally they don’t define the range, but the way to iterate the range. On the other hand, any hardcoded values is evil, so I should request them in constructor and I don’t want to have them in every iterator’s ctor. Putting them intoTokenRange
and copy toIterator
on creation to be able to adjust seems as overengineering.
The code
Here is the updated code for the code review; could you please take a look and suggest further ways to improve?
Fully functional demo.
#include <algorithm>
#include <cstring>
#include <iostream>
#include <ranges>
#include <vector>
class TokenRange {
std::string_view data;
public:
class Iterator {
const std::string_view delimiters = " ";
const std::vector<std::string_view> stable_lexems = { "i.e.", "etc.", "..." };
const std::string_view inword_lexems = "-\'";
std::string_view data;
mutable size_t next = ((size_t)-1);
public:
Iterator(std::string_view data = {}) : data(data) { skip_delimiters(); }
std::string_view operator*() const;
Iterator& operator++();
friend bool operator==(const Iterator& it1, const Iterator& it2) { return it1.data == it2.data; }
private:
void skip_delimiters();
};
TokenRange(std::string_view data) : data(data) {}
Iterator begin() {
return Iterator(data);
}
Iterator end() {
return {};
}
};
void TokenRange::Iterator::skip_delimiters()
{
size_t skip = 0;
while (skip < data.size() && std::ranges::contains(delimiters, data[skip])) {
++skip;
}
data.remove_prefix(skip);
}
std::string_view TokenRange::Iterator::operator*() const
{
size_t i = 0;
while (!data.empty() && i == 0) {
auto it = std::ranges::find_if(stable_lexems, [&](auto stable_lexem)
{
return data.starts_with(stable_lexem);
});
if (it != stable_lexems.end()) {
next = it->size()+(data.size()-data.size());
return data.substr(0, it->size());
}
while (i < data.size() && !std::ranges::contains(delimiters, data[i])) {
const bool is_inword_char = std::ranges::contains(inword_lexems, data[i]);
if (is_inword_char && i != 0 && std::isalpha(data[i - 1])) {
++i;
continue;
}
if (!std::isalpha(data[i]) && !std::isdigit(data[i])) {
if (i == 0) {
++i;
}
break;
}
++i;
}
}
next = i + (data.size() - data.size());
return data.substr(0,i);
}
TokenRange::Iterator& TokenRange::Iterator::operator++()
{
if (next == ((size_t)-1)) {
operator*();
}
data.remove_prefix(next);
skip_delimiters();
next = ((size_t)-1);
return *this;
}
int main()
{
std::string sample = "Let's consider, this semi-simple sample, i.e. test data with ints: 100, etc. For ... some testing...";
for (auto token : TokenRange(sample)) {
std::cout << token << " | ";
}
}
Performance
I was really surprised with the performance results I got with this new code. Tested on my local PC, the new code is about 18% faster than my original code despite the fact that std::string_view
is at least twice larger (pointer and size) than const char*
.
I expected the new version to be at least somewhat slower if not dramatically, but it seems that std::ranges
library does its work best to provide code which compiler could optimize even better than my original code.
So, at least at this stage no ground for being afraid of expenses.
Being honest, I was used to think that low-level C-style which is closer to assembler language should compile better; the reality is different for our luck.
Here is a demo for those who interested in performance measurements.
Please, note that godbolt.org is not suited for performance measurements (at least, as I know it), so the results could differ dramatically. You could just copy the code to your local PC and check.
std::size_t
. It's not portable to assume that the standard library will define global-namespace equivalents ofstd
-namespace identifiers (it's allowed to, but not required to). And you're still passing possibly-negative characters tostd::isalpha()
andstd::isdigit()
. It doesn't look like you've taken advice. \$\endgroup\$isalpha()
is not safe and switching tostd::isalpha()
should solve the problem alongside with moving tostd
functions. I will check more careful this time and will make the adjustment. On thestd::size_t
, as I answered in my comment to your previous answer, taking into account howstd::size_t
defined I made conscious decision to usesize_t
. Please see the standard 17.7/2 and 18.1/3 which makes them absolutely interchangeable and portable. Someday I could change my mind, but so farsize_t
is enough for me. \$\endgroup\$std::isalpha()
from<cctype>
andstd::isaplha
from<locales>
. The latter can be used directly with plainchar
but not the former. \$\endgroup\$size_t
, onlystd::size_t
. If you really need an unqualified version, you need to include a C compatibility header or to writeusing std::size_t;
. It is not portable to assume thatsize_t
is defined everywhere thatstd::size_t
is. \$\endgroup\$unsigned char
here? \$\endgroup\$