Как обработать отказ выпустить ресурс, который содержится в интеллектуальном указателе?

Как должен ошибка во время освобождения ресурса быть обработанным, когда объект, представляющий ресурс, содержится в общем указателе?

РЕДАКТИРОВАНИЕ 1:

Помещать этот вопрос в более конкретные условия: Много интерфейсов C-стиля имеют функцию для выделения ресурса, и один для выпуска его. Примеры, открывают (2) и близко (2) для дескрипторов файлов в системах POSIX, XOpenDisplay и XCloseDisplay для соединения с X-сервером, или sqlite3_open и sqlite3_close для соединения с базой данных SQLite.

Мне нравится инкапсулировать такие интерфейсы в классе C++, с помощью идиомы Pimpl для сокрытия деталей реализации, и обеспечивая метод фабрики, возвращая общий указатель, чтобы гарантировать, что ресурс освобожден, когда никакие ссылки на него не остаются.

Но, во всех примерах, данных выше и многие другие, функция, используемая для выпуска ресурса, может сообщить об ошибке. Если эта функция вызвана деструктором, я не могу выдать исключение, потому что обычно деструкторы не должны бросать.

Если с другой стороны, я обеспечиваю открытый метод выпустить ресурс, у меня теперь есть класс с двумя возможными состояниями: Тот, в котором ресурс допустим, и тот, в котором был уже выпущен ресурс. Мало того, что это усложняет реализацию класса, она также открывает потенциал для неправильного использования. Это плохо, потому что интерфейс должен иметь целью совершать невозможные ошибки использования.

Я был бы благодарен за любую справку с этой проблемой.

Исходный оператор вопроса и мысли о возможном решении следуют ниже.

РЕДАКТИРОВАНИЕ 2:

По этому вопросу существует теперь щедрость. Решение должно отвечать этим требованиям:

  1. Ресурс выпущен, если и только если никакие ссылки на него не остаются.
  2. Ссылки на ресурс могут быть уничтожены явно. Исключение выдается, если ошибка произошла при выпуске ресурса.
  3. Не возможно использовать ресурс, который был уже выпущен.
  4. Подсчет ссылок и выпуск ресурса ориентированы на многопотоковое исполнение.

Решение должно отвечать этим требованиям:

  1. Это использует общий указатель, обеспеченный повышением, Техническим отчетом 1 (TR1) C++ и предстоящим стандартом C++, C++ 0x.
  2. Это универсально. Классы ресурса только должны реализовать, как ресурс выпущен.

Спасибо за Ваше время и мысли.

РЕДАКТИРОВАНИЕ 3:

Благодаря всем, кто ответил на мой вопрос.

Ответ Alsk встретил все попросившее в щедрости и был принят. В многопоточном коде это решение потребовало бы отдельного потока очистки.

Я добавил другой ответ, где любые исключения во время очистки выдаются потоком, который на самом деле использовал ресурс без потребности в отдельном потоке очистки. Если Вы все еще интересуетесь этой проблемой (она беспокоила меня много), прокомментируйте.

Интеллектуальные указатели являются полезным инструментом для управления ресурсами безопасно. Примерами таких ресурсов является память, дисковые файлы, соединения с базой данных или сетевые соединения.

// open a connection to the local HTTP port
boost::shared_ptr socket = Socket::connect("localhost:80");

В типичном сценарии класс, инкапсулирующий ресурс, должен быть noncopyable и полиморфным. Хороший способ поддерживать это состоит в том, чтобы обеспечить метод фабрики, возвратив общий указатель и объявить всех непубличных конструкторов. Общие указатели могут теперь быть скопированы с и присвоены свободно. Объект автоматически уничтожается, когда никакая ссылка на него не остается, и деструктор затем выпускает ресурс.

/** A TCP/IP connection. */
class Socket
{
public:
    static boost::shared_ptr connect(const std::string& address);
    virtual ~Socket();
protected:
    Socket(const std::string& address);
private:
    // not implemented
    Socket(const Socket&);
    Socket& operator=(const Socket&);
};

Но существует проблема с этим подходом. Деструктор не должен бросать, таким образом, отказ выпустить ресурс останется необнаруженным.

Распространенный способ из этой проблемы состоит в том, чтобы добавить открытый метод выпустить ресурс.

class Socket
{
public:
    virtual void close(); // may throw
    // ...
};

К сожалению, этот подход представляет другую проблему: Наши объекты могут теперь содержать средства, которые были уже высвобождены. Это усложняет реализацию класса ресурса. Еще хуже, это позволяет клиентам класса использовать его неправильно. Следующий пример может казаться неправдоподобным, но это - распространенная ошибка в многопоточном коде.

socket->close();
// ...
size_t nread = socket->read(&buffer[0], buffer.size()); // wrong use!

Любой мы удостоверяемся, что ресурс не выпущен перед объектом, уничтожается, таким образом, заблудившись для контакта с неудавшимся освобождением ресурса. Или мы позволяем выпускать ресурс явно в течение времени жизни объекта, таким образом, позволяя использовать класс ресурса неправильно.

Существует выход из этой дилеммы. Но решение включает использование измененного общего класса указателя. Эти модификации, вероятно, будут спорны.

Типичные общие реализации указателя, такие как повышение:: shared_ptr, потребуйте, чтобы никакое исключение не было выдано, когда деструктор их объекта называют. Обычно никакой деструктор никогда не должен бросать, таким образом, это - разумное требование. Эти реализации также позволяют пользовательской функции средства удаления быть указанной, который называют вместо деструктора, когда никакая ссылка на объект не остается. Требование без бросков расширяется на эту пользовательскую функцию средства удаления.

Объяснение для этого требования ясно: деструктор общего указателя не должен бросать. Если функция средства удаления не бросает, ни будет деструктор общего указателя. Однако то же содержит для других функций членства общего указателя, которые приводят к освобождению ресурса, например, сбрасывают (): Если освобождение ресурса перестало работать, никакое исключение не может быть выдано.

Решение, предложенное здесь, состоит в том, чтобы позволить пользовательским функциям средства удаления бросать. Это означает, что деструктор измененного общего указателя должен поймать исключения, выданные функцией средства удаления. С другой стороны, функции членства кроме деструктора, например, сброс (), не должны ловить исключения функции средства удаления (и их реализация становится несколько более сложной).

Вот исходный пример, с помощью функции средства удаления броска:

/** A TCP/IP connection. */
class Socket
{
public:
    static SharedPtr connect(const std::string& address);
protected:
    Socket(const std::string& address);
    virtual Socket() { }
private:
    struct Deleter;

    // not implemented
    Socket(const Socket&);
    Socket& operator=(const Socket&);
};

struct Socket::Deleter
{
    void operator()(Socket* socket)
    {
        // Close the connection. If an error occurs, delete the socket
        // and throw an exception.

        delete socket;
    }
};

SharedPtr Socket::connect(const std::string& address)
{
    return SharedPtr(new Socket(address), Deleter());
}

Мы можем теперь использовать сброс () для освобождения ресурса явно. Если существует все еще ссылка на ресурс в другом потоке, или другая часть программы, называя сброс () только постепенно уменьшит подсчет ссылок. Если это - последняя ссылка на ресурс, ресурс выпущен. Если освобождение ресурса перестало работать, исключение выдается.

SharedPtr socket = Socket::connect("localhost:80");
// ...
socket.reset();

Править:

Вот является полное (но зависимый платформы) реализацией средства удаления:

struct Socket::Deleter
{
    void operator()(Socket* socket)
    {
        if (close(socket->m_impl.fd) < 0)
        {
            int error = errno;
            delete socket;
            throw Exception::fromErrno(error);
        }

        delete socket;
     }
};

9
задан Community 23 May 2017 в 12:07
поделиться

6 ответов

Нам нужно где-то хранить выделенные ресурсы (как уже упоминалось в DeadMG ) и явно вызывать некоторую функцию отчета / выброса вне любого деструктора.Но это не мешает нам воспользоваться подсчетом ссылок, реализованным в boost :: shared_ptr.

/** A TCP/IP connection. */
class Socket
{
private:
    //store internally every allocated resource here
    static std::vector<boost::shared_ptr<Socket> > pool;
public:
    static boost::shared_ptr<Socket> connect(const std::string& address)
    {
         //...
         boost::shared_ptr<Socket> socket(new Socket(address));
         pool.push_back(socket); //the socket won't be actually 
                                 //destroyed until we want it to
         return socket;
    }
    virtual ~Socket();

    //call cleanupAndReport() as often as needed
    //probably, on a separate thread, or by timer 
    static void cleanupAndReport()
    {
        //find resources without clients
        foreach(boost::shared_ptr<Socket>& socket, pool)
        {
            if(socket.unique()) //there are no clients for this socket, i.e. 
                  //there are no shared_ptr's elsewhere pointing to this socket
            {
                 //try to deallocate this resource
                 if (close(socket->m_impl.fd) < 0)
                 {
                     int error = errno;
                     socket.reset(); //destroys Socket object
                     //throw an exception or handle error in-place
                     //... 
                     //throw Exception::fromErrno(error);
                 }
                 else
                 {
                     socket.reset();
                 } 
            } 
        } //foreach socket
    }
protected:
    Socket(const std::string& address);
private:
    // not implemented
    Socket(const Socket&);
    Socket& operator=(const Socket&);
};

Реализация cleanupAndReport () должна быть немного сложнее: в текущей версии пул заполняется нулевыми указателями после очистки, и в случае выдачи исключения мы должны вызывать функцию до тех пор, пока она не перестанет генерироваться и т. Д. , но я надеюсь, это хорошо иллюстрирует идею.

Теперь более общее решение:

//forward declarations
template<class Resource>
boost::shared_ptr<Resource> make_shared_resource();
template<class Resource>
void cleanupAndReport(boost::function1<void,boost::shared_ptr<Resource> deallocator);

//for every type of used resource there will be a template instance with a static pool
template<class Resource>
class pool_holder
{
private:
        friend boost::shared_ptr<Resource> make_shared_resource<Resource>();
        friend void cleanupAndReport(boost::function1<void,boost::shared_ptr<Resource>);
        static std::vector<boost::shared_ptr<Resource> > pool;
};
template<class Resource>
std::vector<boost::shared_ptr<Resource> > pool_holder<Resource>::pool;

template<class Resource>
boost::shared_ptr<Resource> make_shared_resource()
{
        boost::shared_ptr<Resource> res(new Resource);
        pool_holder<Resource>::pool.push_back(res);
        return res;
}
template<class Resource>
void cleanupAndReport(boost::function1<void,boost::shared_ptr<Resource> > deallocator)
{
    foreach(boost::shared_ptr<Resource>& res, pool_holder<Resource>::pool)
    {
        if(res.unique()) 
        {
             deallocator(res);
        }
    } //foreach
}
//usage
        {
           boost::shared_ptr<A> a = make_shared_resource<A>();
           boost::shared_ptr<A> a2 = make_shared_resource<A>();
           boost::shared_ptr<B> b = make_shared_resource<B>();
           //...
        }
        cleanupAndReport<A>(deallocate_A);
        cleanupAndReport<B>(deallocate_B);
4
ответ дан 4 December 2019 в 21:08
поделиться

Если при освобождении некоторого ресурса может произойти сбой , то деструктор, очевидно, является неправильной абстракцией для использования. Деструкторы предназначены для обязательной очистки, независимо от обстоятельств. Метод close () (или как вы хотите его назвать), вероятно, единственный выход.

Но подумайте об этом повнимательнее. Если освободить ресурс не удается , что вы можете сделать? Можно ли исправить такую ​​ошибку? Если да, то какая часть вашего кода должна с этим справиться? Способ восстановления, вероятно, сильно зависит от приложения и связан с другими частями приложения. Маловероятно, что вы действительно хотите, чтобы это происходило автоматически , в произвольном месте кода, в котором произошло освобождение ресурса и возникновение ошибки. Абстракция общего указателя на самом деле не моделирует то, чего вы пытаетесь достичь. Если да, то вам явно необходимо создать свою собственную абстракцию, которая моделирует запрошенное вами поведение. Злоупотреблять общими указателями, чтобы делать то, чего они не должны делать, - неправильный путь.

Также прочтите это .

РЕДАКТИРОВАТЬ:
Если все, что вы хотите сделать, это проинформировать пользователя о том, что произошло до сбоя, подумайте о том, чтобы поместить сокет в другой объект-оболочку , который будет вызывать удаление при его уничтожении перехватывайте любые возникшие исключения и обрабатывайте их, показывая пользователю окно сообщения или что-то еще. Затем поместите этот объект-оболочку в boost :: shared_ptr .

4
ответ дан 4 December 2019 в 21:08
поделиться

Во-первых, я не вижу здесь вопроса. Во-вторых, я должен сказать, что это плохая идея. Что вы во всем этом выиграете? Когда последний общий указатель на ресурс будет уничтожен и будет вызван ваш бросающий удалитель, вы обнаружите, что произошла утечка ресурсов. Вы потеряете все дескрипторы ресурса, который не удалось освободить. Вы никогда не сможете повторить попытку.

Ваше желание использовать объект RAII - хорошее, но умного указателя просто недостаточно для этой задачи. То, что вам нужно, должно быть еще умнее. Вам нужно что-то, что может восстановиться, если не рухнет полностью. Деструктора для такого интерфейса недостаточно.

Вы действительно представляете себе неправильное использование, когда кто-то может заставить ресурс иметь дескриптор, но быть недействительным. Тип ресурса, с которым вы здесь имеете дело, просто поддается решению этой проблемы. Есть много способов подойти к этому. Один из методов может заключаться в использовании идиомы дескриптора / тела вместе с шаблоном состояния. Реализация интерфейса может находиться в одном из двух состояний: подключенном или неподключенном. Дескриптор просто передает запросы во внутреннее тело / состояние. Connected работает как обычно, неподключенный выдает исключения / утверждения во всех применимых запросах.

Для уничтожения дескриптора этой вещи нужна другая функция, кроме ~. Вы можете рассмотреть функцию destroy (), которая может вызывать. Если вы поймаете ошибку при ее вызове, вы не удаляете дескриптор, а вместо этого решаете проблему любым способом, который вам нужен для конкретного приложения.Если вы не поймаете ошибку от destroy (), вы позволите дескриптору выйти из области видимости, сбросить его или что-то еще. Затем функция destroy () определяет счетчик ресурсов и пытается освободить внутренний ресурс, если этот счетчик равен 0. В случае успеха дескриптор переходит в неподключенное состояние, в случае сбоя он генерирует обнаруживаемую ошибку, которую клиент может попытаться обработать, но уходит. ручка в подключенном состоянии.

Это не совсем тривиальная вещь для написания, но то, что вы хотите сделать, внести исключения в уничтожение, просто не сработает.

0
ответ дан 4 December 2019 в 21:08
поделиться

Вообще говоря, если закрытие ресурса в стиле C не работает, то это скорее проблема с API, чем проблема в вашем коде. Однако, я бы хотел сделать следующее: если уничтожение не удалось, добавить его в список ресурсов, которые нужно уничтожить/очистить повторно позже, скажем, при выходе из приложения, периодически, или когда другие подобные ресурсы будут уничтожены, а затем попытаться уничтожить повторно. Если таковые останутся в произвольное время, выдать ошибку пользователю и выйти.

0
ответ дан 4 December 2019 в 21:08
поделиться

Цитата Херба Саттера, автора из "Exceptional C ++" (из здесь ):

Если деструктор генерирует исключение, Плохие вещи могут случиться. Конкретно, рассмотрите следующий код:

//  The problem
//
class X {
public:
  ~X() { throw 1; }
};

void f() {
  X x;
  throw 2;
} // calls X::~X (which throws), then calls terminate()

Если деструктор выдает исключение в то время как другое исключение уже активен (т.е. во время разматывания стопки), программа завершена. Это обычно не очень хорошо.

Другими словами, независимо от того, что вы хотели бы считать элегантным в этой ситуации, вы не можете беспечно генерировать исключение в деструкторе, если вы не можете гарантировать, что оно не будет сгенерировано при обработке другого исключения.

Кроме того, что делать, если не удается избавиться от ресурса? Исключения следует создавать для вещей, которые могут быть обработаны на более высоком уровне, а не для ошибок. Если вы хотите сообщить о странном поведении, зарегистрируйте ошибку выпуска и просто продолжайте. Или прекратить.

1
ответ дан 4 December 2019 в 21:08
поделиться

Как объявлено в вопросе, отредактируйте 3:

Вот еще одно решение, которое, насколько я могу судить, соответствует требования в вопросе. Это похоже на описанное решение в исходном вопросе, но использует boost :: shared_ptr вместо настраиваемый умный указатель.

Основная идея этого решения - предоставить release () операция на shared_ptr . Если мы сможем заставить shared_ptr отказаться от право собственности, мы можем вызвать функцию очистки, удалить объект, и генерировать исключение, если во время очистки произошла ошибка.

Boost имеет хорошо причина не предоставлять операцию release () для shared_ptr :

shared_ptr не может передавать право собственности, если оно не является unique (), потому что другая копия все равно уничтожит объект.

Примите во внимание:

 shared_ptr  a (новый int);
shared_ptr  b (а); // a.use_count () == b.use_count () == 2
int * p = a.release ();

// Кому сейчас принадлежит p? b по-прежнему будет вызывать удаление для него в своем деструкторе.

Кроме того, указатель, возвращаемый функцией release (), будет трудно надежно освободить, так как источник shared_ptr мог быть создан с настраиваемым удалением.

Первый аргумент против операции release () заключается в том, что природа shared_ptr , многие указатели совместно владеют объектом, так что ни один из них не может просто отказаться от этой собственности.Но что если функция release () вернула нулевой указатель, если был еще ссылки остались? shared_ptr может надежно определить это без условий гонки.

Второй аргумент против операции release () заключается в том, что если пользовательский удалитель был передан в shared_ptr , вы должны использовать его для освободить объект, а не просто удалить его. Но релиз () может вернуть объект функции в дополнение к необработанному указателю на разрешить вызывающей стороне надежно освободить указатель.

Однако в нашем конкретном сценарии пользовательские удалители не будут проблема, потому что нам не нужно иметь дело с произвольным обычаем удалители. Это станет яснее из приведенного ниже кода.

Обеспечение операции release () для shared_ptr без изменения его реализация, конечно, невозможна без взлома. В hack, который используется в приведенном ниже коде, полагается на локальную переменную потока чтобы предотвратить фактическое удаление объекта нашим пользовательским средством удаления.

Тем не менее, вот код, состоящий в основном из заголовка Resource.hpp плюс небольшой файл реализации Resource.cpp . Примечание что он должен быть связан с -lboost_thread-mt из-за локальная переменная потока.

// ---------------------------------------------------------------------
// Resource.hpp
// ---------------------------------------------------------------------

#include <boost/assert.hpp>
#include <boost/ref.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/thread/tss.hpp>


/// Factory for a resource.
template<typename T>
struct ResourceFactory
{
    /// Create a resource.
    static boost::shared_ptr<T>
    create()
    {
        return boost::shared_ptr<T>(new T, ResourceFactory());
    }

    template<typename A1>
    static boost::shared_ptr<T>
    create(const A1& a1)
    {
        return boost::shared_ptr<T>(new T(a1), ResourceFactory());
    }

    template<typename A1, typename A2>
    static boost::shared_ptr<T>
    create(const A1& a1, const A2& a2)
    {
        return boost::shared_ptr<T>(new T(a1, a2), ResourceFactory());
    }

    // ...

    /// Destroy a resource.
    static void destroy(boost::shared_ptr<T>& resource);

    /// Deleter for boost::shared_ptr<T>.
    void operator()(T* resource);
};


namespace impl
{

// ---------------------------------------------------------------------

/// Return the last reference to the resource, or zero. Resets the pointer.
template<typename T>
T* release(boost::shared_ptr<T>& resource);

/// Return true if the resource should be deleted (thread-local).
bool wantDelete();

// ---------------------------------------------------------------------

} // namespace impl


template<typename T>
inline
void ResourceFactory<T>::destroy(boost::shared_ptr<T>& ptr)
{
    T* resource = impl::release(ptr);

    if (resource != 0) // Is it the last reference?
    {
        try
        {
            resource->close();
        }
        catch (...)
        {
            delete resource;

            throw;
        }

        delete resource;
    }
}

// ---------------------------------------------------------------------

template<typename T>
inline
void ResourceFactory<T>::operator()(T* resource)
{
    if (impl::wantDelete())
    {
        try
        {
            resource->close();
        }
        catch (...)
        {
        }

        delete resource;
    }
}


namespace impl
{

// ---------------------------------------------------------------------

/// Flag in thread-local storage.
class Flag
{
public:
    ~Flag()
    {
        m_ptr.release();
    }

    Flag& operator=(bool value)
    {
        if (value != static_cast<bool>(*this))
        {
            if (value)
            {
                m_ptr.reset(s_true); // may throw boost::thread_resource_error!
            }
            else
            {
                m_ptr.release();
            }
        }

        return *this;
    }

    operator bool()
    {
        return m_ptr.get() == s_true;
    }

private:
    boost::thread_specific_ptr<char> m_ptr;

    static char* s_true;
};

// ---------------------------------------------------------------------

/// Flag to prevent deletion.
extern Flag t_nodelete;

// ---------------------------------------------------------------------

/// Return the last reference to the resource, or zero.
template<typename T>
T* release(boost::shared_ptr<T>& resource)
{
    try
    {
        BOOST_ASSERT(!t_nodelete);

        t_nodelete = true; // may throw boost::thread_resource_error!
    }
    catch (...)
    {
        t_nodelete = false;

        resource.reset();

        throw;
    }

    T* rv = resource.get();

    resource.reset();

    return wantDelete() ? rv : 0;
}

// ---------------------------------------------------------------------

} // namespace impl

И файл реализации:

// ---------------------------------------------------------------------
// Resource.cpp
// ---------------------------------------------------------------------

#include "Resource.hpp"


namespace impl
{

// ---------------------------------------------------------------------

bool wantDelete()
{
    bool rv = !t_nodelete;

    t_nodelete = false;

    return rv;
}

// ---------------------------------------------------------------------

Flag t_nodelete;

// ---------------------------------------------------------------------

char* Flag::s_true((char*)0x1);

// ---------------------------------------------------------------------

} // namespace impl

А вот пример класса ресурсов, реализованного с использованием этого решения:

// ---------------------------------------------------------------------
// example.cpp
// ---------------------------------------------------------------------
#include "Resource.hpp"

#include <cstdlib>
#include <string>
#include <stdexcept>
#include <iostream>


// uncomment to test failed resource allocation, usage, and deallocation

//#define TEST_CREAT_FAILURE
//#define TEST_USAGE_FAILURE
//#define TEST_CLOSE_FAILURE

// ---------------------------------------------------------------------

/// The low-level resource type.
struct foo { char c; };

// ---------------------------------------------------------------------

/// The low-level function to allocate the resource.
foo* foo_open()
{
#ifdef TEST_CREAT_FAILURE
    return 0;
#else
    return (foo*) std::malloc(sizeof(foo));
#endif
}

// ---------------------------------------------------------------------

/// Some low-level function using the resource.
int foo_use(foo*)
{
#ifdef TEST_USAGE_FAILURE
    return -1;
#else
    return 0;
#endif
}

// ---------------------------------------------------------------------

/// The low-level function to free the resource.
int foo_close(foo* foo)
{
    std::free(foo);
#ifdef TEST_CLOSE_FAILURE
    return -1;
#else
    return 0;
#endif
}

// ---------------------------------------------------------------------

/// The C++ wrapper around the low-level resource.
class Foo
{
public:
    void use()
    {
        if (foo_use(m_foo) < 0)
        {
            throw std::runtime_error("foo_use");
        }
    }

protected:
    Foo()
        : m_foo(foo_open())
    {
        if (m_foo == 0)
        {
            throw std::runtime_error("foo_open");
        }
    }

    void close()
    {
        if (foo_close(m_foo) < 0)
        {
            throw std::runtime_error("foo_close");
        }
    }

private:
    foo* m_foo;

    friend struct ResourceFactory<Foo>;
};

// ---------------------------------------------------------------------

typedef ResourceFactory<Foo> FooFactory;

// ---------------------------------------------------------------------

/// Main function.
int main()
{
    try
    {
        boost::shared_ptr<Foo> resource = FooFactory::create();

        resource->use();

        FooFactory::destroy(resource);
    }
    catch (const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
    }

    return 0;
}

Наконец, вот небольшой Makefile для создания всего этого:

# Makefile

CXXFLAGS = -g -Wall

example: example.cpp Resource.hpp Resource.o
    $(CXX) $(CXXFLAGS) -o example example.cpp Resource.o -lboost_thread-mt

Resource.o: Resource.cpp Resource.hpp
    $(CXX) $(CXXFLAGS) -c Resource.cpp -o Resource.o

clean:
    rm -f Resource.o example
1
ответ дан 4 December 2019 в 21:08
поделиться
Другие вопросы по тегам:

Похожие вопросы: