По сравнению со многими современными языками язык Си зачастую кажется крайне примитивным и небезопасным. И одной из частых претензий к языку является невозможность доступа из кода в его же внутреннее представление. В других языках это традиционно осуществляется механизмами, вроде reflections, и довольно удобно в применении.
Тем не менее, с появлением libclang, можно писать собственные анализаторы и генераторы кода прямо в compile time, устраняя достаточно большое множество проблем на ранних этапах работы. Сочетание инструментов статического анализа общего плана (coverity, clang-scan), инструментов анализа для конкретного проекта, а также дисциплины написания кода позволяет намного улучшить качество и безопасность кода, написанного на Си. Конечно, это не даст гарантий, каких дает haskell или даже rust, но позволяет существенно оптимизировать процесс разработки, особенно в случае, когда переписывать огромный проект на другом языке является нереальной задачей.
В данной статье я хотел бы поделиться опытом создания плагина статического анализа format argument для функций, похожих на printf. В ходе написания плагина, мне пришлось очень много рыться в исходниках и doxygen документации libclang, поэтому я счел полезным сделать некоторый обзор для тех, кто хочет ступить на этот тернистый путь, но пока еще не уверен в целесообразности траты времени на сбор информации. В статье не будет картинок, и даже картинок блюющих единорогов, простите.
Проблема анализа printf like функций стояла у меня в проекте (https://rspamd.com) довольно давно: стандартный printf из libc не устраивал меня по многим причинам:
Поэтому в свое время я взял printf из nginx и адаптировал его для своих задач. Пример кода можно посмотреть тут. У данного подхода есть один недостаток — он совершенно отключает работу стандартного анализатора query string из компилятора, а статические анализаторы общего плана неспособны понять, какие аргументы что значат. Однако эта задача идеально решается при помощи абстрактного синтаксического дерева (AST) компилятора, доступ к которому предоставляется через libclang.
Плагин обработки AST должен выполнять следующие задачи:
Несмотря на то что примеров работы с libclang в интернете достаточно, большинство из них посвящены больше анализу определений, а не анализу выражений, кроме того, почему-то множество примеров написаны на Питоне, писать на котором при наличии прекрасного (на мой взгляд) C++11 мне решительно не хотелось (хотя время компиляции прототипов на C++ — это основной серьезный недостаток).
Первой проблемой, с которой я столкнулся, было то, что разные версии llvm предоставляют разные API. Кроме того, например, osx сборка llvm, установленная через macports, оказалась неработоспособной от слова «никак». Поэтому, я просто установил llvm на свою linux песочницу и работал конкретно с этой версией — 3.7. Впрочем, данный код должен также работать и на 3.6+.
Второй проблемой оказалась система сборки. В моем проекте используется cmake, поэтому я хотел, конечно же, использовать его для построения плагина. Идея была в том, что при включенной опции собирать плагин, а затем уже использовать его для сборки остальной части кода. В первую очередь, как заведено с cmake, пришлось писать пакет для нахождения в системе llvm и libclang, расстановку CXX флагов (например, включение c++11 стандарта). К сожалению, из-за неработоспособности llvm в osx, это напрочь отломало интеграцию с замечательной IDE CLion, которую я использую для повседневной работы, поэтому писать код пришлось без дополнений и прочих удобств, предлагаемых IDE.
Компиляция плагина проблем особых не вызвала:
А вот с включением его для работы с остальным кодом возникли проблемы. Во-первых, cmake проявлял недюжинный искусственный интеллект, группируя зачем-то опции компилятора, превращая
Как вы видите, пришлось явно указать путь до полученной библиотеки, что потенциально ломало работу системы под osx (где используется .dylib вместо .so), но это было малозначимым фактором из-за неработоспособности llvm под osx. Второй проблемой явилось то, что если указать
В данной части я не хотел бы сильно акцентировать внимание на основах создания плагинов — этому посвящено довольно много материалов. Вкратце, плагин создается при помощи статического метода
Основным интересным методом является метод
Здесь мы создаем ASTVisitor, который посещает узлы дерева, и выполняем обход дерева компиляции. В данном классе, собственно, и делается вся работа по анализу вызова функций. Определен этот класс предельно просто (используя pimpl идиому):
Основная мысль — наследование от
В данном кусочке кода, мы получаем определение (декларацию) функции из выражения и извлекаем имя функции. Дальше мы ищем в хеше
Число означает позицию query string в аргументах. Далее, если функция нас интересует, мы извлекаем query string и анализируем его (для этого я написал автомат, который несколько за рамками данной статьи):
В этом фрагменте важно то, что мы вначале пытаемся вычислить query string, если это возможно. Это полезно, например, если query string у нас формируется при помощи какого-либо выражения. К сожалению, работа со значениями в libclang делается достаточно трудно: нужно взять выражение, оценить его (EvaluateAsRValue), взять результат, который уже можно преобразовать в LValue, и далее в
Далее я анализировал query string и получал вектор таких структур:
Каждая такая структура содержит метод вызова, который принимает аргумент (
Функция
Соответственно, для вывода предупреждения нужно использовать
Анализ типов выполняется, в целом, двумя методами. Один умеет проверять встроенные типы, например, long/int итд, а второй — сложные типы, например, структуры. Для проверки простых типов используется
Итоговая функция проверки простых типов выглядит так:
Как видно, для снятия алиасов используется метод
Для сложных типов необходимо выделить имя структуры, перечисления или объединения. Функция проверки выглядит так:
Так как мы предполагаем, что аргумент у нас не структура, а указатель на нее, то вначале мы определяем тип указателя через
Разумеется, после написания плагина я начал проверять, как он работает на своем основном коде. Были как простые проблемы с типами:
Так и серьезные проблемы:
А также проблемы с числом аргументов:
Всего было найдено 47 проблем с format query, что можно увидеть в следующем коммите: http://git.io/v8Nyv
Код плагина доступен здесь.
Тем не менее, с появлением libclang, можно писать собственные анализаторы и генераторы кода прямо в compile time, устраняя достаточно большое множество проблем на ранних этапах работы. Сочетание инструментов статического анализа общего плана (coverity, clang-scan), инструментов анализа для конкретного проекта, а также дисциплины написания кода позволяет намного улучшить качество и безопасность кода, написанного на Си. Конечно, это не даст гарантий, каких дает haskell или даже rust, но позволяет существенно оптимизировать процесс разработки, особенно в случае, когда переписывать огромный проект на другом языке является нереальной задачей.
В данной статье я хотел бы поделиться опытом создания плагина статического анализа format argument для функций, похожих на printf. В ходе написания плагина, мне пришлось очень много рыться в исходниках и doxygen документации libclang, поэтому я счел полезным сделать некоторый обзор для тех, кто хочет ступить на этот тернистый путь, но пока еще не уверен в целесообразности траты времени на сбор информации. В статье не будет картинок, и даже картинок блюющих единорогов, простите.
Постановка задачи
Проблема анализа printf like функций стояла у меня в проекте (https://rspamd.com) довольно давно: стандартный printf из libc не устраивал меня по многим причинам:
- при печати в буфер, printf(3) пытается распарсить всю format string целиком, даже если она включает огромные null-terminated строки, а буфер назначения очень мал:
snprintf(buf, 16, "%s", str)
, гдеstr
— очень длинная строка; такое поведение было мне ни к чему - printf крайне плохо понимает fixed length integers (uint32_t, uitn64_t)
- хотелось печатать собственные структуры данных, например, fixed length strings без '\0' в конце
- хотелось более «продвинутых» флагов форматирования: hex encoding, human readable integers и так далее
- хотелось уметь печатать в собственные структуры данных, например, автоматически расширяемые строки
Поэтому в свое время я взял printf из nginx и адаптировал его для своих задач. Пример кода можно посмотреть тут. У данного подхода есть один недостаток — он совершенно отключает работу стандартного анализатора query string из компилятора, а статические анализаторы общего плана неспособны понять, какие аргументы что значат. Однако эта задача идеально решается при помощи абстрактного синтаксического дерева (AST) компилятора, доступ к которому предоставляется через libclang.
Плагин обработки AST должен выполнять следующие задачи:
- Парсинг query string и извлечение из нее всех '%' аргументов
- Сравнение количества аргументов в query string и переданных функции
- Возможность проверки типа каждого аргумента (включая сложные типы)
- Возможность проверки функций, которые принимают query string в разных позициях (например, printf/fprintf/snprintf)
Компиляция и работа с плагином
Несмотря на то что примеров работы с libclang в интернете достаточно, большинство из них посвящены больше анализу определений, а не анализу выражений, кроме того, почему-то множество примеров написаны на Питоне, писать на котором при наличии прекрасного (на мой взгляд) C++11 мне решительно не хотелось (хотя время компиляции прототипов на C++ — это основной серьезный недостаток).
Первой проблемой, с которой я столкнулся, было то, что разные версии llvm предоставляют разные API. Кроме того, например, osx сборка llvm, установленная через macports, оказалась неработоспособной от слова «никак». Поэтому, я просто установил llvm на свою linux песочницу и работал конкретно с этой версией — 3.7. Впрочем, данный код должен также работать и на 3.6+.
Второй проблемой оказалась система сборки. В моем проекте используется cmake, поэтому я хотел, конечно же, использовать его для построения плагина. Идея была в том, что при включенной опции собирать плагин, а затем уже использовать его для сборки остальной части кода. В первую очередь, как заведено с cmake, пришлось писать пакет для нахождения в системе llvm и libclang, расстановку CXX флагов (например, включение c++11 стандарта). К сожалению, из-за неработоспособности llvm в osx, это напрочь отломало интеграцию с замечательной IDE CLion, которую я использую для повседневной работы, поэтому писать код пришлось без дополнений и прочих удобств, предлагаемых IDE.
Компиляция плагина проблем особых не вызвала:
FIND_PACKAGE(LLVM REQUIRED)
SET(CLANGPLUGINSRC plugin.cc printf_check.cc)
ADD_LIBRARY(rspamd-clang SHARED ${CLANGPLUGINSRC})
SET_TARGET_PROPERTIES(rspamd-clang PROPERTIES
COMPILE_FLAGS "${LLVM_CXX_FLAGS} ${LLVM_CPP_FLAGS} ${LLVM_C_FLAGS}"
INCLUDE_DIRECTORIES ${LIBCLANG_INCLUDE_DIR}
LINKER_LANGUAGE CXX)
TARGET_LINK_LIBRARIES(rspamd-clang ${LIBCLANG_LIBRARIES})
LINK_DIRECTORIES(${LLVM_LIBRARY_DIRS})
А вот с включением его для работы с остальным кодом возникли проблемы. Во-первых, cmake проявлял недюжинный искусственный интеллект, группируя зачем-то опции компилятора, превращая
-Xclang opt1 -Xclang opt2
в -Xclang opt1 opt2
, что напрочь ломало компиляцию. Выход нашел через прямую установку CMAKE_C_FLAGS
:IF (ENABLE_CLANG_PLUGIN MATCHES "ON")
SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Xclang -load -Xclang ${CMAKE_CURRENT_BINARY_DIR}/../clang-plugin/librspamd-clang.so -Xclang -add-plugin -Xclang rspamd-ast")
ENDIF ()
Как вы видите, пришлось явно указать путь до полученной библиотеки, что потенциально ломало работу системы под osx (где используется .dylib вместо .so), но это было малозначимым фактором из-за неработоспособности llvm под osx. Второй проблемой явилось то, что если указать
-Xclang -plugin
, как рекоммендуется почти во всех примерах, то clang перестает компилировать исходники (то есть, он не генерирует объектные файлы), выполняя исключительно анализ. Выходом из ситуации явилась замена -Xclang -plugin
на -Xclang -add-plugin
, что нашлось после некоторой медитации над выдачей гугла.Написание плагина
В данной части я не хотел бы сильно акцентировать внимание на основах создания плагинов — этому посвящено довольно много материалов. Вкратце, плагин создается при помощи статического метода
clang::FrontendPluginRegistry::Add
, который регистрирует плагин для clang. Данный метод является шаблонным, и он принимает тип класса, который наследуется от clang::PluginASTAction
и определяет в нем нужные методы:class RspamdASTAction : public PluginASTAction {
protected:
std::unique_ptr <ASTConsumer> CreateASTConsumer (CompilerInstance &CI,
llvm::StringRef) override
{
return llvm::make_unique<RspamdASTConsumer> (CI);
}
bool ParseArgs (const CompilerInstance &CI,
const std::vector <std::string> &args) override
{
return true;
}
void PrintHelp (llvm::raw_ostream &ros)
{
ros << "Nothing here\n";
}
};
static FrontendPluginRegistry::Add <rspamd::RspamdASTAction>
X ("rspamd-ast", "rspamd ast checker");
Основным интересным методом является метод
CreateASTConsumer
, который говорит clang'у, что полученный объект нужно вызвать на этапе, когда компилятор выполнил трансляцию кода в синтаксическое дерево. Вся дальнейшая работа ведется в ASTConsumer, в котором в свою очередь определен метод HandleTranslationUnit
, который, собственно, получает контекст синтаксического дерева. CompilerInstance
используется для управления компилятором, например, для генерации ошибок и предупреждений, что крайне удобно при работе с плагином. Целиком ASTConsumer описан так:class RspamdASTConsumer : public ASTConsumer {
CompilerInstance &Instance;
public:
RspamdASTConsumer (CompilerInstance &Instance)
: Instance (Instance)
{
}
void HandleTranslationUnit (ASTContext &context) override
{
rspamd::PrintfCheckVisitor v(&context, Instance);
v.TraverseDecl (context.getTranslationUnitDecl ());
}
};
Здесь мы создаем ASTVisitor, который посещает узлы дерева, и выполняем обход дерева компиляции. В данном классе, собственно, и делается вся работа по анализу вызова функций. Определен этот класс предельно просто (используя pimpl идиому):
class PrintfCheckVisitor : public clang::RecursiveASTVisitor<PrintfCheckVisitor> {
class impl;
std::unique_ptr<impl> pimpl;
public:
PrintfCheckVisitor (clang::ASTContext *ctx, clang::CompilerInstance &ci);
virtual ~PrintfCheckVisitor (void);
bool VisitCallExpr (clang::CallExpr *E);
};
Основная мысль — наследование от
clang::RecursiveASTVisitor
, выполняющего обход дерева, и определение метода VisitCallExpr
, который вызывается при нахождении в дереве вызова функции. В данном методе (проксированном в pimpl) выполняется основная работа по разбору функций и их аргументов. Начинается метод так:bool VisitCallExpr (CallExpr *E)
{
auto callee = dyn_cast<NamedDecl> (E->getCalleeDecl ());
if (callee == NULL) {
llvm::errs () << "Bad callee\n";
return false;
}
auto fname = callee->getNameAsString ();
auto pos_it = printf_functions.find (fname);
if (pos_it != printf_functions.end ()) {
В данном кусочке кода, мы получаем определение (декларацию) функции из выражения и извлекаем имя функции. Дальше мы ищем в хеше
printf_functions
, интересует ли нас данная функция:printf_functions = {
{"rspamd_printf", 0},
{"rspamd_default_log_function", 4},
{"rspamd_snprintf", 2},
{"rspamd_fprintf", 1}
};
Число означает позицию query string в аргументах. Далее, если функция нас интересует, мы извлекаем query string и анализируем его (для этого я написал автомат, который несколько за рамками данной статьи):
const auto args = E->getArgs ();
auto pos = pos_it->second;
auto query = args[pos];
if (!query->isEvaluatable (*pcontext)) {
print_warning (std::string ("cannot evaluate query"),
E, this->pcontext, this->ci);
return false;
}
clang::Expr::EvalResult r;
if (!query->EvaluateAsRValue (r, *pcontext)) {
print_warning (std::string ("cannot evaluate rvalue of query"),
E, this->pcontext, this->ci);
return false;
}
auto qval = dyn_cast<StringLiteral> (
r.Val.getLValueBase ().get<const Expr *> ());
if (!qval) {
print_warning (std::string ("bad or absent query string"),
E, this->pcontext, this->ci);
return false;
}
В этом фрагменте важно то, что мы вначале пытаемся вычислить query string, если это возможно. Это полезно, например, если query string у нас формируется при помощи какого-либо выражения. К сожалению, работа со значениями в libclang делается достаточно трудно: нужно взять выражение, оценить его (EvaluateAsRValue), взять результат, который уже можно преобразовать в LValue, и далее в
StringLiteral
. Если вычисление не нужно, то можно брать непосредственно Expr *
и приводить его к StringLiteral
, что сильно упрощает код.Далее я анализировал query string и получал вектор таких структур:
struct PrintfArgChecker {
private:
arg_parser_t parser;
public:
int width;
int precision;
bool is_unsigned;
ASTContext *past;
CompilerInstance *pci;
PrintfArgChecker (arg_parser_t _p, ASTContext *_ast, CompilerInstance *_ci) :
parser (_p), past (_ast), pci(_ci)
{
width = 0;
precision = 0;
is_unsigned = false;
}
virtual ~PrintfArgChecker ()
{
}
bool operator() (const Expr *e)
{
return parser (e, this);
}
};
Каждая такая структура содержит метод вызова, который принимает аргумент (
Expr *
) и проверяет его тип на соответствие заданному. Дальше мы просто проверяем все аргументы после query string на соответствие типам:if (parsers->size () != E->getNumArgs () - (pos + 1)) {
std::ostringstream err_buf;
err_buf << "number of arguments for " << fname
<< " missmatches query string '" << qval->getString ().str ()
<< "', expected " << parsers->size () << " args"
<< ", got " << (E->getNumArgs () - (pos + 1)) << " args";
print_error (err_buf.str (), E, this->pcontext, this->ci);
return false;
}
else {
for (auto i = pos + 1; i < E->getNumArgs (); i++) {
auto arg = args[i];
if (arg) {
if (!parsers->at (i - (pos + 1)) (arg)) {
return false;
}
}
}
}
Функция
print_error
интересна тем, что она умеет печатать ошибку компиляции и прекращать процесс компиляции. Делается это через CompilerInstance
, но довольно неочевидным способом:static void
print_error (const std::string &err, const Expr *e, const ASTContext *ast,
CompilerInstance *ci)
{
auto loc = e->getExprLoc ();
auto &diag = ci->getDiagnostics ();
auto id = diag.getCustomDiagID (DiagnosticsEngine::Error,
"format query error: %0");
diag.Report (loc, id) << err;
}
Соответственно, для вывода предупреждения нужно использовать
DiagnosticsEngine::Warning
.Анализ типов выполняется, в целом, двумя методами. Один умеет проверять встроенные типы, например, long/int итд, а второй — сложные типы, например, структуры. Для проверки простых типов используется
clang::BuiltinType::Kind
, который определяет все известные клангу типы. Возможные значения можно поискать в /usr/include/clang/AST/BuiltinTypes.def
(для линукса). Тут есть две тонкости:- Fixed size int могут по-разному совпадать с built-in type, поэтому надо делать проверки вида
if (sizeof (int32_t) == sizeof (int)) {...} if (sizeof (int32_t) == sizeof (long)) {...}
- Аргументы могут быть алиасами на другие типы, поэтому вначале их надо от этих алиасов избавить, например
typedef my_int int
Итоговая функция проверки простых типов выглядит так:
static bool
check_builtin_type (const Expr *arg, struct PrintfArgChecker *ctx,
const std::vector <BuiltinType::Kind> &k, const std::string &fmt)
{
auto type = arg->getType ().split ().Ty;
auto desugared_type = type->getUnqualifiedDesugaredType ();
if (!desugared_type->isBuiltinType ()) {
print_error (
std::string ("not a builtin type for ") + fmt + " arg: " +
arg->getType ().getAsString (),
arg, ctx->past, ctx->pci);
return false;
}
auto builtin_type = dyn_cast<BuiltinType> (desugared_type);
auto kind = builtin_type->getKind ();
auto found = false;
for (auto kk : k) {
if (kind == kk) {
found = true;
break;
}
}
if (!found) {
print_error (
std::string ("bad argument for ") + fmt + " arg: " +
arg->getType ().getAsString () + ", resolved as: " +
builtin_type->getNameAsCString (ctx->past->getPrintingPolicy ()),
arg, ctx->past, ctx->pci);
return false;
}
return true;
}
Как видно, для снятия алиасов используется метод
getUnqualifiedDesugaredType
, а для получения типа выражения из выражения — arg->getType()
. Но данный метод возвращает qualified type (например, включая спецификатор const
), что для данной задачи не нужно, поэтому qualified type разделяется split
, а из получившейся структуры берется только чистый тип.Для сложных типов необходимо выделить имя структуры, перечисления или объединения. Функция проверки выглядит так:
static bool
check_struct_type (const Expr *arg, struct PrintfArgChecker *ctx,
const std::string &sname, const std::string &fmt)
{
auto type = arg->getType ().split ().Ty;
if (!type->isPointerType ()) {
print_error (
std::string ("bad string argument for %s: ") +
arg->getType ().getAsString (),
arg, ctx->past, ctx->pci);
return false;
}
auto ptr_type = type->getPointeeType ().split ().Ty;
auto desugared_type = ptr_type->getUnqualifiedDesugaredType ();
if (!desugared_type->isRecordType ()) {
print_error (
std::string ("not a record type for ") + fmt + " arg: " +
arg->getType ().getAsString (),
arg, ctx->past, ctx->pci);
return false;
}
auto struct_type = desugared_type->getAsStructureType ();
auto struct_decl = struct_type->getDecl ();
auto struct_def = struct_decl->getNameAsString ();
if (struct_def != sname) {
print_error (std::string ("bad argument '") + struct_def + "' for "
+ fmt + " arg: " +
arg->getType ().getAsString (),
arg, ctx->past, ctx->pci);
return false;
}
return true;
}
Так как мы предполагаем, что аргумент у нас не структура, а указатель на нее, то вначале мы определяем тип указателя через
type->getPointeeType().split().Ty
. Затем выполняем desugaring и находим декларацию типа: struct_type->getDecl()
. После чего проверки делаются достаточно тривиальным способом.Результаты
Разумеется, после написания плагина я начал проверять, как он работает на своем основном коде. Были как простые проблемы с типами:
[ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libutil/map.c.o src/libutil/map.c:906:46: error: format query error: bad argument for %z arg: guint, resolved as: unsigned int msg_info_pool ("read hash of %z elements", g_hash_table_size ^ src/libutil/logger.h:190:9: note: expanded from macro 'msg_info_pool' __VA_ARGS__) ^ 1 error generated.
Так и серьезные проблемы:
[ 45%] Building C object src/CMakeFiles/rspamd-server.dir/libserver/protocol.c.o src/libserver/protocol.c:373:45: error: format query error: bad argument 'f_str_tok' for %V arg: rspamd_ftok_t * msg_err_task ("bad from header: '%V'", h->value); ^ src/libutil/logger.h:164:9: note: expanded from macro 'msg_err_task' __VA_ARGS__) ^ 1 error generated. [ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libstat/tokenizers/osb.c.o src/libstat/tokenizers/osb.c:128:48: error: format query error: bad string argument for %s: gsize msg_warn ("siphash key is too short: %s", keylen); ^ src/libutil/logger.h:145:9: note: expanded from macro 'msg_warn' __VA_ARGS__) ^ 1 error generated.
А также проблемы с числом аргументов:
[ 46%] Building C object src/CMakeFiles/rspamd-server.dir/libmime/mime_expressions.c.o src/libmime/mime_expressions.c:780:3: error: format query error: number of arguments for rspamd_default_log_function missmatches query string 'process test regexp %s for url %s returned FALSE', expected 2 args, got 1 args msg_info_task ("process test regexp %s for url %s returned FALSE", ^ src/libutil/logger.h:169:30: note: expanded from macro 'msg_info_task' #define msg_info_task(...) rspamd_default_log_function (G_LOG_LEVEL_INFO, ^ 1 error generated.
Всего было найдено 47 проблем с format query, что можно увидеть в следующем коммите: http://git.io/v8Nyv
Код плагина доступен здесь.
Комментарии (5)
helper2424
12.11.2015 19:49Извиняюсь за оффтоп, но как давно вы используете CLion? С удовольствием использую IDE от Jetbrains для Java и Ruby. Есть огромное желание использовать CLion для работы, но без удаленной сборки и дебага это просто нереально. Приходится работать с NetBeans. Как вы решили эту проблему?
cebka
13.11.2015 17:24Я не использую IDE для сборки и дебага — мне хватает для этого gdb — поэтому я вряд ли могу что-то сказать по этому вопросу.
encyclopedist
Строку формата не всегда же можно вычислить на этапе компиляции, она может зависеть от каких-то данных. Что вы делаете в этом случае? Запрещаете такие строки и выдаёте ошибку или игнорируете?
encyclopedist
Уже сам нашёл в коде: выдаётся предупреждение.
cebka
Да, таких случаев у меня получилось аж две штуки в проекте. И каждый, я думаю, надо рефакторить на самом деле.